Move and modularize GUI dialog files
Refactored dialog-related code by splitting ytsage_gui_dialogs.py into multiple modular files under src/gui/dialogs/. Added new files for base, custom, ffmpeg, selection, and settings dialogs, improving code organization and maintainability. Removed the old monolithic ytsage_gui_dialogs.py.
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Base dialogs for YTSage application.
|
||||
Contains basic utility dialogs like LogWindow and AboutDialog.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import webbrowser
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QTextEdit, QWidget, QDialogButtonBox, QSizePolicy,
|
||||
QPushButton, QMessageBox, QScrollArea)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from ...core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_path
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path, check_ytdlp_installed
|
||||
from ...core.ytsage_utils import (check_ffmpeg, get_ytdlp_version, get_ffmpeg_version,
|
||||
refresh_version_cache, _version_cache)
|
||||
|
||||
|
||||
class LogWindow(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle('yt-dlp Log')
|
||||
self.setMinimumSize(700, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.log_text = QTextEdit()
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #2b2b2b;
|
||||
color: #ffffff;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout.addWidget(self.log_text)
|
||||
|
||||
def append_log(self, message):
|
||||
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):
|
||||
super().__init__(parent)
|
||||
self.parent = parent # Store parent to access version etc.
|
||||
self.setWindowTitle("About YTSage")
|
||||
self.setMinimumSize(460, 420) # Slightly increased to accommodate paths
|
||||
self.resize(460, 440) # Slightly increased initial size
|
||||
self.setMaximumSize(500, 480) # Reasonable maximum size
|
||||
|
||||
# Set window flags to make dialog independent of parent movement
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(15) # Reduced spacing
|
||||
layout.setContentsMargins(20, 20, 20, 20) # Reduced margins
|
||||
layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetMinAndMaxSize)
|
||||
|
||||
# App Information Section
|
||||
app_info_widget = self._create_app_info_section()
|
||||
layout.addWidget(app_info_widget)
|
||||
|
||||
# Separator - subtle and compact
|
||||
separator = QWidget()
|
||||
separator.setFixedHeight(1)
|
||||
separator.setStyleSheet("background-color: #2a2a2a; margin: 8px 20px;")
|
||||
layout.addWidget(separator)
|
||||
|
||||
# System Information Section
|
||||
system_info_widget = self._create_system_info_section()
|
||||
layout.addWidget(system_info_widget)
|
||||
|
||||
# Close Button - compact positioning
|
||||
layout.addSpacing(10) # Reduced space before button
|
||||
button_layout = QHBoxLayout()
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
||||
button_box.accepted.connect(self.accept)
|
||||
|
||||
# Center the button
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(button_box)
|
||||
button_layout.addStretch()
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Apply overall styling - improved consistency
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 30px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QGroupBox {
|
||||
font-weight: bold;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 15px;
|
||||
padding: 0 10px 0 10px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
}
|
||||
""")
|
||||
|
||||
def _create_app_info_section(self):
|
||||
"""Create the application information section - compact version"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setSpacing(6) # Reduced spacing
|
||||
|
||||
# Title and Version - more compact
|
||||
title_label = QLabel("<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
version_label = QLabel(f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self.parent, 'version', '4.7.0')}</span>")
|
||||
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# Description - more compact
|
||||
description_label = QLabel("A simple GUI frontend for the powerful yt-dlp video downloader.")
|
||||
description_label.setWordWrap(True)
|
||||
description_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
description_label.setStyleSheet("color: #ffffff; font-size: 11px; margin: 6px 0;")
|
||||
layout.addWidget(description_label)
|
||||
|
||||
# Author and Links - compact single line
|
||||
info_layout = QHBoxLayout()
|
||||
info_layout.setSpacing(15)
|
||||
|
||||
author_label = QLabel("By: <a href='https://github.com/oop7/' style='color: #c90000; text-decoration: none; font-size: 10px;'>oop7</a>")
|
||||
author_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(author_label)
|
||||
|
||||
repo_label = QLabel("GitHub: <a href='https://github.com/oop7/YTSage/' style='color: #c90000; text-decoration: none; font-size: 10px;'>YTSage</a>")
|
||||
repo_label.setOpenExternalLinks(True)
|
||||
info_layout.addWidget(repo_label)
|
||||
|
||||
# Center the info layout
|
||||
info_container = QHBoxLayout()
|
||||
info_container.addStretch()
|
||||
info_container.addLayout(info_layout)
|
||||
info_container.addStretch()
|
||||
|
||||
layout.addLayout(info_container)
|
||||
|
||||
return widget
|
||||
|
||||
def _create_system_info_section(self):
|
||||
"""Create the system information section with compact design"""
|
||||
# Create main container with compact styling
|
||||
container = QWidget()
|
||||
container.setStyleSheet("""
|
||||
QWidget {
|
||||
border: 1px solid #333333;
|
||||
border-radius: 8px;
|
||||
background-color: #15181b;
|
||||
margin-top: 5px;
|
||||
}
|
||||
""")
|
||||
|
||||
main_layout = QVBoxLayout(container)
|
||||
main_layout.setSpacing(8) # Compact spacing
|
||||
main_layout.setContentsMargins(15, 10, 15, 10)
|
||||
|
||||
# Create header with title and refresh button on same line
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 0, 5)
|
||||
|
||||
# System Information title
|
||||
title_label = QLabel("System Information")
|
||||
title_label.setStyleSheet("""
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
""")
|
||||
header_layout.addWidget(title_label)
|
||||
|
||||
# Add stretch to push refresh button to the right
|
||||
header_layout.addStretch()
|
||||
|
||||
# Create refresh button
|
||||
self.refresh_btn = QPushButton("🔄")
|
||||
self.refresh_btn.setFixedSize(16, 16)
|
||||
self.refresh_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
padding: 0px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
font-size: 10px;
|
||||
font-weight: normal;
|
||||
margin: 0px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
color: #c90000;
|
||||
background-color: rgba(201, 0, 0, 0.1);
|
||||
}
|
||||
""")
|
||||
self.refresh_btn.clicked.connect(self.refresh_version_info)
|
||||
header_layout.addWidget(self.refresh_btn)
|
||||
|
||||
main_layout.addLayout(header_layout)
|
||||
|
||||
# Compact status grid layout
|
||||
self.status_container = QVBoxLayout()
|
||||
self.status_container.setSpacing(6) # Tight spacing
|
||||
self.status_container.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
main_layout.addLayout(self.status_container)
|
||||
|
||||
# Show loading message initially
|
||||
self._show_loading_message()
|
||||
|
||||
# Populate system information asynchronously
|
||||
QTimer.singleShot(100, self.update_system_info)
|
||||
|
||||
return container
|
||||
|
||||
def _show_loading_message(self):
|
||||
"""Show a compact loading message while system information is being gathered."""
|
||||
loading_label = QLabel("🔄 Loading system information...")
|
||||
loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
loading_label.setStyleSheet("""
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 10px;
|
||||
}
|
||||
""")
|
||||
|
||||
self.status_container.addWidget(loading_label)
|
||||
|
||||
def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status=""):
|
||||
"""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(path_text) > 60:
|
||||
truncated_path = "..." + 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):
|
||||
"""Update the system information display with compact layout."""
|
||||
# Clear existing items
|
||||
for i in reversed(range(self.status_container.count())):
|
||||
child = self.status_container.itemAt(i).widget()
|
||||
if child:
|
||||
child.deleteLater()
|
||||
|
||||
# yt-dlp Status - compact version with path
|
||||
ytdlp_found = check_ytdlp_installed()
|
||||
ytdlp_status_text = "<span style='color: #4CAF50;'>✓ Detected</span>" if ytdlp_found else "<span style='color: #F44336;'>✗ Missing</span>"
|
||||
ytdlp_version = get_ytdlp_version()
|
||||
|
||||
# Get yt-dlp path
|
||||
ytdlp_path = get_yt_dlp_path() if ytdlp_found else None
|
||||
ytdlp_path_text = ytdlp_path if ytdlp_path and ytdlp_path != "yt-dlp" else None
|
||||
|
||||
# Simplified cache status
|
||||
ytdlp_cache = _version_cache.get('ytdlp', {})
|
||||
last_check = ytdlp_cache.get('last_check', 0)
|
||||
cache_status = ""
|
||||
if last_check > 0:
|
||||
from datetime import datetime
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||
|
||||
ytdlp_item = self._create_status_item(
|
||||
"🎥", "yt-dlp", ytdlp_status_text, ytdlp_version + cache_status, ytdlp_path_text
|
||||
)
|
||||
self.status_container.addWidget(ytdlp_item)
|
||||
|
||||
# FFmpeg Status - compact version with path
|
||||
ffmpeg_found = check_ffmpeg()
|
||||
ffmpeg_status_text = "<span style='color: #4CAF50;'>✓ Detected</span>" if ffmpeg_found else "<span style='color: #F44336;'>✗ Missing</span>"
|
||||
ffmpeg_version = get_ffmpeg_version() if ffmpeg_found else "Not Available"
|
||||
|
||||
# Get FFmpeg path
|
||||
ffmpeg_path_text = None
|
||||
if ffmpeg_found:
|
||||
ffmpeg_path = get_ffmpeg_path()
|
||||
ffmpeg_path_text = ffmpeg_path if ffmpeg_path else None
|
||||
|
||||
# Simplified cache status for FFmpeg
|
||||
ffmpeg_cache = _version_cache.get('ffmpeg', {})
|
||||
last_check = ffmpeg_cache.get('last_check', 0)
|
||||
cache_status = ""
|
||||
if last_check > 0 and ffmpeg_found:
|
||||
from datetime import datetime
|
||||
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
|
||||
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
|
||||
|
||||
ffmpeg_item = self._create_status_item(
|
||||
"🎬", "FFmpeg", ffmpeg_status_text, ffmpeg_version + cache_status, ffmpeg_path_text
|
||||
)
|
||||
self.status_container.addWidget(ffmpeg_item)
|
||||
|
||||
def refresh_version_info(self):
|
||||
"""Refresh version information manually."""
|
||||
self.refresh_btn.setText("🔄 Refreshing...")
|
||||
self.refresh_btn.setEnabled(False)
|
||||
|
||||
# Perform refresh in a separate thread to avoid blocking UI
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
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):
|
||||
"""Handle refresh completion."""
|
||||
self.refresh_btn.setText("🔄 Refresh")
|
||||
self.refresh_btn.setEnabled(True)
|
||||
|
||||
if success:
|
||||
self.update_system_info()
|
||||
else:
|
||||
# Show error message with proper styling
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Warning)
|
||||
msg_box.setWindowTitle("Refresh Failed")
|
||||
msg_box.setText("Failed to refresh version information.")
|
||||
msg_box.setWindowIcon(self.windowIcon())
|
||||
msg_box.setStyleSheet("""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
""")
|
||||
msg_box.exec()
|
||||
@@ -0,0 +1,735 @@
|
||||
"""
|
||||
Custom functionality dialogs for YTSage application.
|
||||
Contains dialogs for custom commands, cookies, time ranges, and other special features.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QTextEdit, QPlainTextEdit,
|
||||
QCheckBox, QTabWidget, QWidget, QDialogButtonBox,
|
||||
QFileDialog, QGroupBox)
|
||||
from PySide6.QtCore import Qt, QMetaObject, Q_ARG
|
||||
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
|
||||
try:
|
||||
import yt_dlp
|
||||
YT_DLP_AVAILABLE = True
|
||||
except ImportError:
|
||||
YT_DLP_AVAILABLE = False
|
||||
|
||||
|
||||
class CustomCommandDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
self.setWindowTitle('Custom yt-dlp Command')
|
||||
self.setMinimumSize(600, 400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
|
||||
"Example: --extract-audio --audio-format mp3 --audio-quality 0\n"
|
||||
"Note: Download path and output template will be preserved."
|
||||
)
|
||||
help_text.setWordWrap(True)
|
||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Command input
|
||||
self.command_input = QPlainTextEdit()
|
||||
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
||||
self.command_input.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.command_input)
|
||||
|
||||
# Add SponsorBlock checkbox
|
||||
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
||||
self.sponsorblock_checkbox.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
""")
|
||||
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.run_btn = QPushButton("Run Command")
|
||||
self.run_btn.clicked.connect(self.run_custom_command)
|
||||
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
button_layout.addWidget(self.run_btn)
|
||||
button_layout.addWidget(self.close_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Log output
|
||||
self.log_output = QTextEdit()
|
||||
self.log_output.setReadOnly(True)
|
||||
self.log_output.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.log_output)
|
||||
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
""")
|
||||
|
||||
def run_custom_command(self):
|
||||
url = self.parent.url_input.text().strip()
|
||||
if not url:
|
||||
self.log_output.append("Error: No URL provided")
|
||||
return
|
||||
|
||||
command = self.command_input.toPlainText().strip()
|
||||
path = self.parent.path_input.text().strip()
|
||||
|
||||
self.log_output.clear()
|
||||
self.log_output.append(f"Running command with URL: {url}")
|
||||
self.run_btn.setEnabled(False)
|
||||
|
||||
# Start command in thread
|
||||
threading.Thread(target=self._run_command_thread,
|
||||
args=(command, url, path),
|
||||
daemon=True).start()
|
||||
|
||||
def _run_command_thread(self, command, url, path):
|
||||
try:
|
||||
class CommandLogger:
|
||||
def debug(self, msg):
|
||||
self.dialog.log_output.append(msg)
|
||||
def warning(self, msg):
|
||||
self.dialog.log_output.append(f"Warning: {msg}")
|
||||
def error(self, msg):
|
||||
self.dialog.log_output.append(f"Error: {msg}")
|
||||
def __init__(self, dialog):
|
||||
self.dialog = dialog
|
||||
|
||||
# Split command into arguments
|
||||
args = command.split()
|
||||
|
||||
# Base options
|
||||
ydl_opts = {
|
||||
'logger': CommandLogger(self),
|
||||
'paths': {'home': path},
|
||||
'debug_printout': True,
|
||||
'postprocessors': []
|
||||
}
|
||||
|
||||
# Add SponsorBlock options if enabled
|
||||
if self.sponsorblock_checkbox.isChecked():
|
||||
ydl_opts['postprocessors'].extend([{
|
||||
'key': 'SponsorBlock',
|
||||
'categories': ['sponsor', 'selfpromo', 'interaction'],
|
||||
'api': 'https://sponsor.ajay.app'
|
||||
}, {
|
||||
'key': 'ModifyChapters',
|
||||
'remove_sponsor_segments': ['sponsor', 'selfpromo', 'interaction'],
|
||||
'sponsorblock_chapter_title': '[SponsorBlock]: %(category_names)l',
|
||||
'force_keyframes': True
|
||||
}])
|
||||
|
||||
# Add custom arguments
|
||||
for i in range(0, len(args), 2):
|
||||
if i + 1 < len(args):
|
||||
key = args[i].lstrip('-').replace('-', '_')
|
||||
value = args[i + 1]
|
||||
try:
|
||||
# Try to convert to appropriate type
|
||||
if value.lower() in ('true', 'false'):
|
||||
value = value.lower() == 'true'
|
||||
elif value.isdigit():
|
||||
value = int(value)
|
||||
ydl_opts[key] = value
|
||||
except:
|
||||
ydl_opts[key] = value
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
self.log_output.append("Command completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.log_output.append(f"Error: {str(e)}")
|
||||
finally:
|
||||
self.run_btn.setEnabled(True)
|
||||
|
||||
|
||||
class CookieLoginDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle('Login with Cookies')
|
||||
self.setMinimumSize(400, 150)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
help_text = QLabel(
|
||||
"Select the Netscape-format cookies file for logging in.\n"
|
||||
"This allows downloading of private videos and premium quality audio."
|
||||
)
|
||||
help_text.setWordWrap(True)
|
||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# File path input and browse button
|
||||
path_layout = QHBoxLayout()
|
||||
self.cookie_path_input = QLineEdit()
|
||||
self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
|
||||
path_layout.addWidget(self.cookie_path_input)
|
||||
|
||||
self.browse_button = QPushButton("Browse")
|
||||
self.browse_button.clicked.connect(self.browse_cookie_file)
|
||||
path_layout.addWidget(self.browse_button)
|
||||
|
||||
layout.addLayout(path_layout)
|
||||
|
||||
# Dialog buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def browse_cookie_file(self):
|
||||
# Open file dialog to select cookie file
|
||||
file_dialog = QFileDialog(self)
|
||||
file_dialog.setFileMode(QFileDialog.ExistingFile)
|
||||
file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
|
||||
if file_dialog.exec():
|
||||
selected_files = file_dialog.selectedFiles()
|
||||
if selected_files:
|
||||
self.cookie_path_input.setText(selected_files[0])
|
||||
|
||||
def get_cookie_file_path(self):
|
||||
# Return the selected cookie file path
|
||||
return self.cookie_path_input.text()
|
||||
|
||||
|
||||
class CustomOptionsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
self.setWindowTitle('Custom Options')
|
||||
self.setMinimumSize(600, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create tab widget to organize content
|
||||
self.tab_widget = QTabWidget()
|
||||
layout.addWidget(self.tab_widget)
|
||||
|
||||
# === Cookies Tab ===
|
||||
cookies_tab = QWidget()
|
||||
cookies_layout = QVBoxLayout(cookies_tab)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"Select the Netscape-format cookies file for logging in.\n"
|
||||
"This allows downloading of private videos and premium quality audio."
|
||||
)
|
||||
help_text.setWordWrap(True)
|
||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
cookies_layout.addWidget(help_text)
|
||||
|
||||
# File path input and browse button
|
||||
path_layout = QHBoxLayout()
|
||||
self.cookie_path_input = QLineEdit()
|
||||
self.cookie_path_input.setPlaceholderText("Path to cookies file (Netscape format)")
|
||||
if hasattr(parent, 'cookie_file_path') and parent.cookie_file_path:
|
||||
self.cookie_path_input.setText(parent.cookie_file_path)
|
||||
path_layout.addWidget(self.cookie_path_input)
|
||||
|
||||
self.browse_button = QPushButton("Browse")
|
||||
self.browse_button.clicked.connect(self.browse_cookie_file)
|
||||
path_layout.addWidget(self.browse_button)
|
||||
cookies_layout.addLayout(path_layout) # Add the horizontal layout to cookies layout
|
||||
|
||||
# Status indicator for cookies
|
||||
self.cookie_status = QLabel("")
|
||||
self.cookie_status.setStyleSheet("color: #999999; font-style: italic;")
|
||||
cookies_layout.addWidget(self.cookie_status)
|
||||
|
||||
cookies_layout.addStretch()
|
||||
|
||||
# === Custom Command Tab ===
|
||||
command_tab = QWidget()
|
||||
command_layout = QVBoxLayout(command_tab)
|
||||
|
||||
# Help text
|
||||
cmd_help_text = QLabel(
|
||||
"Enter custom yt-dlp commands below. The URL will be automatically appended.\n"
|
||||
"Example: --extract-audio --audio-format mp3 --audio-quality 0\n"
|
||||
"Note: Download path and output template will be preserved."
|
||||
)
|
||||
cmd_help_text.setWordWrap(True)
|
||||
cmd_help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
command_layout.addWidget(cmd_help_text)
|
||||
|
||||
# Add SponsorBlock checkbox
|
||||
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
|
||||
self.sponsorblock_checkbox.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
}
|
||||
""")
|
||||
command_layout.addWidget(self.sponsorblock_checkbox)
|
||||
|
||||
# Command input
|
||||
self.command_input = QPlainTextEdit()
|
||||
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
|
||||
self.command_input.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
""")
|
||||
command_layout.addWidget(self.command_input)
|
||||
|
||||
# Run command button
|
||||
self.run_btn = QPushButton("Run Command")
|
||||
self.run_btn.clicked.connect(self.run_custom_command)
|
||||
command_layout.addWidget(self.run_btn)
|
||||
|
||||
# Log output
|
||||
self.log_output = QTextEdit()
|
||||
self.log_output.setReadOnly(True)
|
||||
self.log_output.setStyleSheet("""
|
||||
QTextEdit {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
""")
|
||||
command_layout.addWidget(self.log_output)
|
||||
|
||||
# Add tabs to the tab widget
|
||||
self.tab_widget.addTab(cookies_tab, "Login with Cookies")
|
||||
self.tab_widget.addTab(command_tab, "Custom Command")
|
||||
|
||||
# Dialog buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Apply global styles
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #3d3d3d;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #c90000;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #2a2d36;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
""")
|
||||
|
||||
def browse_cookie_file(self):
|
||||
# Open file dialog to select cookie file
|
||||
file_dialog = QFileDialog(self)
|
||||
file_dialog.setFileMode(QFileDialog.ExistingFile)
|
||||
file_dialog.setNameFilter("Cookies files (*.txt *.lwp)") # Common cookie file extensions
|
||||
if file_dialog.exec():
|
||||
selected_files = file_dialog.selectedFiles()
|
||||
if selected_files:
|
||||
self.cookie_path_input.setText(selected_files[0])
|
||||
self.cookie_status.setText("Cookie file selected - Click OK to apply")
|
||||
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
|
||||
|
||||
def get_cookie_file_path(self):
|
||||
# Return the selected cookie file path if it's not empty
|
||||
path = self.cookie_path_input.text().strip()
|
||||
if path and os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
def run_custom_command(self):
|
||||
url = self.parent.url_input.text().strip()
|
||||
if not url:
|
||||
self.log_output.append("Error: No URL provided")
|
||||
return
|
||||
|
||||
command = self.command_input.toPlainText().strip()
|
||||
|
||||
# Get download path from parent
|
||||
path = self.parent.last_path
|
||||
|
||||
self.log_output.clear()
|
||||
self.log_output.append(f"Running command with URL: {url}")
|
||||
self.run_btn.setEnabled(False)
|
||||
|
||||
# Start command in thread
|
||||
threading.Thread(target=self._run_command_thread,
|
||||
args=(command, url, path),
|
||||
daemon=True).start()
|
||||
|
||||
def _run_command_thread(self, command, url, path):
|
||||
try:
|
||||
class CommandLogger:
|
||||
def debug(self, msg):
|
||||
QMetaObject.invokeMethod(
|
||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, msg)
|
||||
)
|
||||
def warning(self, msg):
|
||||
QMetaObject.invokeMethod(
|
||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, f"Warning: {msg}")
|
||||
)
|
||||
def error(self, msg):
|
||||
QMetaObject.invokeMethod(
|
||||
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, f"Error: {msg}")
|
||||
)
|
||||
def __init__(self, dialog):
|
||||
self.dialog = dialog
|
||||
|
||||
# Split command into arguments
|
||||
args = command.split()
|
||||
|
||||
# Add SponsorBlock if selected
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
base_cmd = [yt_dlp_path] + args + [url]
|
||||
|
||||
if self.sponsorblock_checkbox.isChecked():
|
||||
base_cmd.extend(['--sponsorblock-remove', 'sponsor,selfpromo,interaction'])
|
||||
|
||||
# Show the full command
|
||||
QMetaObject.invokeMethod(
|
||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, f"Full command: {' '.join(base_cmd)}")
|
||||
)
|
||||
|
||||
# Run the command
|
||||
proc = subprocess.Popen(
|
||||
base_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace'
|
||||
)
|
||||
|
||||
# Stream output
|
||||
for line in proc.stdout:
|
||||
QMetaObject.invokeMethod(
|
||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, line.rstrip())
|
||||
)
|
||||
|
||||
ret = proc.wait()
|
||||
if ret != 0:
|
||||
QMetaObject.invokeMethod(
|
||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, f"Command exited with code {ret}")
|
||||
)
|
||||
else:
|
||||
QMetaObject.invokeMethod(
|
||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, "Command completed successfully")
|
||||
)
|
||||
except Exception as e:
|
||||
QMetaObject.invokeMethod(
|
||||
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, f"Error: {str(e)}")
|
||||
)
|
||||
finally:
|
||||
# Re-enable the run button
|
||||
QMetaObject.invokeMethod(
|
||||
self.run_btn, "setEnabled", Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(bool, True)
|
||||
)
|
||||
|
||||
|
||||
class TimeRangeDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.parent = parent
|
||||
self.setWindowTitle('Download Video Section')
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Help text explaining the feature
|
||||
help_text = QLabel(
|
||||
"Download only specific parts of a video by specifying time ranges.\n"
|
||||
"Use HH:MM:SS format or seconds. Leave start or end empty to download from beginning or to end."
|
||||
)
|
||||
help_text.setWordWrap(True)
|
||||
help_text.setStyleSheet("color: #999999; padding: 10px;")
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Time range section
|
||||
time_group = QGroupBox("Time Range")
|
||||
time_layout = QVBoxLayout()
|
||||
|
||||
# Start time row
|
||||
start_layout = QHBoxLayout()
|
||||
start_layout.addWidget(QLabel("Start Time:"))
|
||||
self.start_time_input = QLineEdit()
|
||||
self.start_time_input.setPlaceholderText("00:00:00 (or leave empty for start)")
|
||||
start_layout.addWidget(self.start_time_input)
|
||||
time_layout.addLayout(start_layout)
|
||||
|
||||
# End time row
|
||||
end_layout = QHBoxLayout()
|
||||
end_layout.addWidget(QLabel("End Time:"))
|
||||
self.end_time_input = QLineEdit()
|
||||
self.end_time_input.setPlaceholderText("00:10:00 (or leave empty for end)")
|
||||
end_layout.addWidget(self.end_time_input)
|
||||
time_layout.addLayout(end_layout)
|
||||
|
||||
time_group.setLayout(time_layout)
|
||||
layout.addWidget(time_group)
|
||||
|
||||
# Force keyframes option
|
||||
self.force_keyframes = QCheckBox("Force keyframes at cuts (better accuracy, slower)")
|
||||
self.force_keyframes.setChecked(True)
|
||||
self.force_keyframes.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.force_keyframes)
|
||||
|
||||
# Format preview
|
||||
preview_group = QGroupBox("Command Preview")
|
||||
preview_layout = QVBoxLayout()
|
||||
self.preview_label = QLabel("--download-sections \"*-\"")
|
||||
self.preview_label.setStyleSheet("""
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
color: #ffffff;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: Consolas, monospace;
|
||||
}
|
||||
""")
|
||||
preview_layout.addWidget(self.preview_label)
|
||||
preview_group.setLayout(preview_layout)
|
||||
layout.addWidget(preview_group)
|
||||
|
||||
# Connect signals for live preview updates
|
||||
self.start_time_input.textChanged.connect(self.update_preview)
|
||||
self.end_time_input.textChanged.connect(self.update_preview)
|
||||
self.force_keyframes.stateChanged.connect(self.update_preview)
|
||||
|
||||
# Buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Apply styling
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QGroupBox {
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
margin-top: 1.5ex;
|
||||
color: #ffffff;
|
||||
padding: 10px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
padding: 0 5px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
""")
|
||||
|
||||
# Initialize preview
|
||||
self.update_preview()
|
||||
|
||||
def update_preview(self):
|
||||
start = self.start_time_input.text().strip()
|
||||
end = self.end_time_input.text().strip()
|
||||
|
||||
if start and end:
|
||||
time_range = f"*{start}-{end}"
|
||||
elif start:
|
||||
time_range = f"*{start}-"
|
||||
elif end:
|
||||
time_range = f"*-{end}"
|
||||
else:
|
||||
time_range = "*-" # Full video
|
||||
|
||||
preview = f"--download-sections \"{time_range}\""
|
||||
if self.force_keyframes.isChecked():
|
||||
preview += " --force-keyframes-at-cuts"
|
||||
|
||||
self.preview_label.setText(preview)
|
||||
|
||||
def get_download_sections(self):
|
||||
"""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):
|
||||
"""Returns whether to force keyframes at cuts"""
|
||||
return self.force_keyframes.isChecked()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
FFmpeg installation dialogs for YTSage application.
|
||||
Contains dialogs and threads for checking and installing FFmpeg.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import webbrowser
|
||||
import contextlib
|
||||
from io import StringIO
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QStyle, QSizePolicy, QDialogButtonBox)
|
||||
from PySide6.QtCore import QThread, Signal, Qt
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from ...core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
|
||||
|
||||
|
||||
class FFmpegInstallThread(QThread):
|
||||
finished = Signal(bool)
|
||||
progress = Signal(str)
|
||||
|
||||
def run(self):
|
||||
# Redirect stdout to capture progress messages
|
||||
output = StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
success = auto_install_ffmpeg()
|
||||
|
||||
# Process captured output and emit progress signals
|
||||
for line in output.getvalue().splitlines():
|
||||
self.progress.emit(line)
|
||||
|
||||
self.finished.emit(success)
|
||||
|
||||
|
||||
class FFmpegCheckDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle('FFmpeg Installation')
|
||||
self.setMinimumWidth(450)
|
||||
self.setMinimumHeight(200)
|
||||
self.resize(450, 220)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent and parent.windowIcon():
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
else:
|
||||
# Try to load the icon directly if parent not available
|
||||
# Navigate from src/gui/dialogs/ to project root, then to assets/Icon/
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__)) # dialogs/
|
||||
gui_dir = os.path.dirname(current_dir) # gui/
|
||||
src_dir = os.path.dirname(gui_dir) # src/
|
||||
project_root = os.path.dirname(src_dir) # project root
|
||||
icon_path = os.path.join(project_root, 'assets', 'Icon', 'icon.png')
|
||||
if os.path.exists(icon_path):
|
||||
self.setWindowIcon(QIcon(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")
|
||||
header_text.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; padding: 5px 0;")
|
||||
header_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(header_text)
|
||||
|
||||
# Message
|
||||
self.message_label = QLabel(
|
||||
"YTSage needs FFmpeg to process videos.\n\n"
|
||||
"Choose an installation option below:"
|
||||
)
|
||||
self.message_label.setWordWrap(True)
|
||||
self.message_label.setStyleSheet("font-size: 13px; color: #cccccc; padding: 10px 0; line-height: 1.4;")
|
||||
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.message_label)
|
||||
|
||||
# Progress label with improved styling and compact height
|
||||
self.progress_label = QLabel("")
|
||||
self.progress_label.setWordWrap(True)
|
||||
self.progress_label.setMinimumHeight(60) # Smaller but visible area
|
||||
self.progress_label.setMaximumHeight(80) # Limit maximum height
|
||||
self.progress_label.setStyleSheet("""
|
||||
QLabel {
|
||||
background-color: #1d1e22;
|
||||
color: #cccccc;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
""")
|
||||
self.progress_label.hide()
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# Add minimal stretch - just enough to push buttons down slightly
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Buttons container - simple approach that should work
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(15) # Simple spacing
|
||||
|
||||
# Install button
|
||||
self.install_btn = QPushButton("Install FFmpeg")
|
||||
self.install_btn.clicked.connect(self.start_installation)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
# Manual install button
|
||||
self.manual_btn = QPushButton("Manual Guide")
|
||||
self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
|
||||
button_layout.addWidget(self.manual_btn)
|
||||
|
||||
# Close button
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
button_layout.addWidget(self.close_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style the dialog to match app theme
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #cccccc;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
""")
|
||||
|
||||
# Initialize installation thread
|
||||
self.install_thread = None
|
||||
|
||||
def start_installation(self):
|
||||
self.install_btn.setEnabled(False)
|
||||
self.manual_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(False)
|
||||
|
||||
# Check if FFmpeg is already installed
|
||||
if check_ffmpeg_installed():
|
||||
self.message_label.setText("FFmpeg is already installed!")
|
||||
self.progress_label.setText("Installation complete. You can close this dialog and continue using YTSage.")
|
||||
self.progress_label.show()
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
self.close_btn.setEnabled(True)
|
||||
return
|
||||
|
||||
self.message_label.setText("Installing FFmpeg... Please wait")
|
||||
self.progress_label.show()
|
||||
|
||||
self.install_thread = FFmpegInstallThread()
|
||||
self.install_thread.finished.connect(self.installation_finished)
|
||||
self.install_thread.progress.connect(self.update_progress)
|
||||
self.install_thread.start()
|
||||
|
||||
def update_progress(self, message):
|
||||
self.progress_label.setText(message)
|
||||
|
||||
def installation_finished(self, success):
|
||||
if success:
|
||||
self.message_label.setText("FFmpeg has been installed successfully!")
|
||||
self.progress_label.setText("Installation complete. You can now close this dialog and continue using YTSage.")
|
||||
self.install_btn.hide()
|
||||
self.manual_btn.hide()
|
||||
else:
|
||||
self.message_label.setText("FFmpeg installation encountered an issue.")
|
||||
self.progress_label.setText("Please try using the manual installation guide instead.")
|
||||
self.install_btn.setEnabled(True)
|
||||
self.manual_btn.setEnabled(True)
|
||||
|
||||
self.close_btn.setEnabled(True)
|
||||
@@ -0,0 +1,651 @@
|
||||
"""
|
||||
Selection dialogs for YTSage application.
|
||||
Contains dialogs for selecting subtitles, playlist videos, and SponsorBlock categories.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QScrollArea, QWidget,
|
||||
QCheckBox, QDialogButtonBox, QGroupBox)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
class SubtitleSelectionDialog(QDialog):
|
||||
def __init__(self, available_manual, available_auto, previously_selected, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Select Subtitles")
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
self.available_manual = available_manual
|
||||
self.available_auto = available_auto
|
||||
self.previously_selected = set(previously_selected) # Use a set for quick lookups
|
||||
self.selected_subtitles = list(previously_selected) # Initialize with previous selection
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Filter input
|
||||
self.filter_input = QLineEdit()
|
||||
self.filter_input.setPlaceholderText("Filter languages (e.g., en, es)...")
|
||||
self.filter_input.textChanged.connect(self.filter_list)
|
||||
self.filter_input.setStyleSheet("""
|
||||
QLineEdit {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border-color: #ff0000;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self.filter_input)
|
||||
|
||||
# Scroll Area for the list
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
# Container widget for list items (needed for scroll area)
|
||||
self.list_container = QWidget()
|
||||
self.list_layout = QVBoxLayout(self.list_container)
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(2) # Compact spacing
|
||||
self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
|
||||
scroll_area.setWidget(self.list_container)
|
||||
|
||||
# Populate the list initially
|
||||
self.populate_list()
|
||||
|
||||
# OK and Cancel buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the buttons
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px; /* Adjust padding */
|
||||
min-height: 30px; /* Ensure consistent height */
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
""")
|
||||
# Style the OK button specifically if needed
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def populate_list(self, filter_text=""):
|
||||
# Clear existing checkboxes from layout
|
||||
while self.list_layout.count():
|
||||
item = self.list_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
|
||||
filter_text = filter_text.lower()
|
||||
combined_subs = {}
|
||||
|
||||
# Add manual subs
|
||||
for lang_code, sub_info in self.available_manual.items():
|
||||
if not filter_text or filter_text in lang_code.lower():
|
||||
combined_subs[lang_code] = f"{lang_code} - Manual"
|
||||
|
||||
# Add auto subs (only if no manual exists and matches filter)
|
||||
for lang_code, sub_info in self.available_auto.items():
|
||||
if lang_code not in combined_subs: # Don't overwrite manual
|
||||
if not filter_text or filter_text in lang_code.lower():
|
||||
combined_subs[lang_code] = f"{lang_code} - Auto-generated"
|
||||
|
||||
if not combined_subs:
|
||||
no_subs_label = QLabel("No subtitles available" + (f" matching '{filter_text}'" if filter_text else ""))
|
||||
no_subs_label.setStyleSheet("color: #aaaaaa; padding: 10px;")
|
||||
self.list_layout.addWidget(no_subs_label)
|
||||
return
|
||||
|
||||
# Sort by language code
|
||||
sorted_lang_codes = sorted(combined_subs.keys())
|
||||
|
||||
for lang_code in sorted_lang_codes:
|
||||
item_text = combined_subs[lang_code]
|
||||
checkbox = QCheckBox(item_text)
|
||||
checkbox.setProperty("subtitle_id", item_text) # Store the identifier
|
||||
checkbox.setChecked(item_text in self.previously_selected) # Check if previously selected
|
||||
checkbox.stateChanged.connect(self.update_selection)
|
||||
checkbox.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px; /* Square checkboxes */
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
""")
|
||||
self.list_layout.addWidget(checkbox)
|
||||
|
||||
self.list_layout.addStretch() # Pushes items up if list is short
|
||||
|
||||
def filter_list(self):
|
||||
self.populate_list(self.filter_input.text())
|
||||
|
||||
def update_selection(self, state):
|
||||
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):
|
||||
# Return the final set as a list
|
||||
return list(self.previously_selected)
|
||||
|
||||
def accept(self):
|
||||
# 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):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Select Playlist Videos")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(400) # Allow more vertical space
|
||||
|
||||
self.playlist_entries = playlist_entries
|
||||
self.checkboxes = []
|
||||
|
||||
# Main layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# Top buttons (Select/Deselect All)
|
||||
button_layout = QHBoxLayout()
|
||||
select_all_btn = QPushButton("Select All")
|
||||
deselect_all_btn = QPushButton("Deselect All")
|
||||
select_all_btn.clicked.connect(self._select_all)
|
||||
deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
# Style the buttons to match the subtitle dialog
|
||||
select_all_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
""")
|
||||
deselect_all_btn.setStyleSheet(select_all_btn.styleSheet())
|
||||
button_layout.addWidget(select_all_btn)
|
||||
button_layout.addWidget(deselect_all_btn)
|
||||
button_layout.addStretch()
|
||||
main_layout.addLayout(button_layout)
|
||||
|
||||
# Scrollable area for checkboxes
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }") # Remove border around scroll area
|
||||
scroll_widget = QWidget()
|
||||
self.list_layout = QVBoxLayout(scroll_widget) # Layout for checkboxes
|
||||
self.list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.list_layout.setSpacing(2) # Compact spacing
|
||||
self.list_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Align items to top
|
||||
scroll_area.setWidget(scroll_widget)
|
||||
main_layout.addWidget(scroll_area)
|
||||
|
||||
# Populate checkboxes
|
||||
self._populate_list(previously_selected_string)
|
||||
|
||||
# Dialog buttons (OK/Cancel)
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the buttons to match subtitle dialog
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #363636;
|
||||
border: 2px solid #3d3d3d;
|
||||
border-radius: 4px;
|
||||
padding: 5px 15px;
|
||||
min-height: 30px;
|
||||
color: white;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #444444;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #555555;
|
||||
}
|
||||
""")
|
||||
# Style the OK button specifically if needed
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(button.styleSheet() + "QPushButton { background-color: #ff0000; border-color: #cc0000; } QPushButton:hover { background-color: #cc0000; }")
|
||||
|
||||
main_layout.addWidget(button_box)
|
||||
|
||||
# Apply styling to match subtitle dialog
|
||||
self.setStyleSheet("""
|
||||
QDialog { background-color: #15181b; }
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
QWidget { background-color: #15181b; }
|
||||
""")
|
||||
|
||||
def _parse_selection_string(self, selection_string):
|
||||
"""Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
|
||||
selected_indices = set()
|
||||
if not selection_string:
|
||||
# If no previous selection, assume all are selected initially
|
||||
return set(range(1, len(self.playlist_entries) + 1))
|
||||
|
||||
parts = selection_string.split(',')
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if '-' in part:
|
||||
try:
|
||||
start, end = map(int, part.split('-'))
|
||||
if start <= end:
|
||||
selected_indices.update(range(start, end + 1))
|
||||
except ValueError:
|
||||
pass # Ignore invalid ranges
|
||||
else:
|
||||
try:
|
||||
selected_indices.add(int(part))
|
||||
except ValueError:
|
||||
pass # Ignore invalid numbers
|
||||
return selected_indices
|
||||
|
||||
def _populate_list(self, previously_selected_string):
|
||||
"""Populates the scroll area with checkboxes for each video."""
|
||||
selected_indices = self._parse_selection_string(previously_selected_string)
|
||||
|
||||
# Clear existing checkboxes if any (e.g., if repopulating)
|
||||
while self.list_layout.count():
|
||||
child = self.list_layout.takeAt(0)
|
||||
if child.widget():
|
||||
child.widget().deleteLater()
|
||||
self.checkboxes.clear()
|
||||
|
||||
for index, entry in enumerate(self.playlist_entries):
|
||||
if not entry:
|
||||
continue # Skip None entries if yt-dlp returns them
|
||||
|
||||
video_index = index + 1 # yt-dlp uses 1-based indexing
|
||||
title = entry.get('title', f'Video {video_index}')
|
||||
# Shorten title if too long
|
||||
display_title = (title[:70] + '...') if len(title) > 73 else title
|
||||
|
||||
checkbox = QCheckBox(f"{video_index}. {display_title}")
|
||||
checkbox.setChecked(video_index in selected_indices)
|
||||
checkbox.setProperty("video_index", video_index) # Store index
|
||||
checkbox.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 5px;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
""")
|
||||
self.list_layout.addWidget(checkbox)
|
||||
self.checkboxes.append(checkbox)
|
||||
self.list_layout.addStretch() # Push checkboxes to the top
|
||||
|
||||
def _select_all(self):
|
||||
for checkbox in self.checkboxes:
|
||||
checkbox.setChecked(True)
|
||||
|
||||
def _deselect_all(self):
|
||||
for checkbox in self.checkboxes:
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def _condense_indices(self, indices):
|
||||
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
|
||||
if not indices:
|
||||
return ""
|
||||
indices = sorted(list(set(indices)))
|
||||
if not indices: # Check again after sorting/set conversion
|
||||
return ""
|
||||
|
||||
ranges = []
|
||||
start = indices[0]
|
||||
end = indices[0]
|
||||
for i in range(1, len(indices)):
|
||||
if indices[i] == end + 1:
|
||||
end = indices[i]
|
||||
else:
|
||||
if start == end:
|
||||
ranges.append(str(start))
|
||||
else:
|
||||
ranges.append(f"{start}-{end}")
|
||||
start = indices[i]
|
||||
end = indices[i]
|
||||
# Add the last range
|
||||
if start == end:
|
||||
ranges.append(str(start))
|
||||
else:
|
||||
ranges.append(f"{start}-{end}")
|
||||
return ",".join(ranges)
|
||||
|
||||
def get_selected_items_string(self):
|
||||
"""Returns the selection string based on checked boxes."""
|
||||
selected_indices = [
|
||||
cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
|
||||
]
|
||||
|
||||
# Check if all items are selected
|
||||
if len(selected_indices) == len(self.playlist_entries):
|
||||
return None # yt-dlp default is all items, so return None or empty string
|
||||
|
||||
return self._condense_indices(selected_indices)
|
||||
|
||||
|
||||
class SponsorBlockCategoryDialog(QDialog):
|
||||
"""Dialog for selecting SponsorBlock categories to remove from videos."""
|
||||
|
||||
# Default SponsorBlock categories with descriptions
|
||||
SPONSORBLOCK_CATEGORIES = {
|
||||
'sponsor': {
|
||||
'name': 'Sponsor',
|
||||
'description': 'Paid promotion, paid referrals and direct advertisements',
|
||||
'default': True
|
||||
},
|
||||
'selfpromo': {
|
||||
'name': 'Unpaid/Self Promotion',
|
||||
'description': 'Unpaid promotion of creators\' own content',
|
||||
'default': True
|
||||
},
|
||||
'interaction': {
|
||||
'name': 'Interaction Reminder',
|
||||
'description': 'Asking viewers to like, subscribe, or follow social media',
|
||||
'default': True
|
||||
},
|
||||
'intro': {
|
||||
'name': 'Intro',
|
||||
'description': 'Video introduction that can be skipped',
|
||||
'default': False
|
||||
},
|
||||
'outro': {
|
||||
'name': 'Outro/End Cards',
|
||||
'description': 'Credits or when the video ends',
|
||||
'default': False
|
||||
},
|
||||
'preview': {
|
||||
'name': 'Preview/Recap',
|
||||
'description': 'Quick recap of previous videos or preview of what\'s coming up',
|
||||
'default': False
|
||||
},
|
||||
'music_offtopic': {
|
||||
'name': 'Non-Music Section',
|
||||
'description': 'Only for music videos. Marks non-music sections',
|
||||
'default': False
|
||||
},
|
||||
'filler': {
|
||||
'name': 'Filler Tangent',
|
||||
'description': 'Tangential scenes added only for filler or humor',
|
||||
'default': False
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, previously_selected=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("SponsorBlock Categories")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(400)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent:
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
|
||||
self.previously_selected = set(previously_selected) if previously_selected else set()
|
||||
self.checkboxes = {}
|
||||
|
||||
self.init_ui()
|
||||
self.apply_styling()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Title and description
|
||||
title_label = QLabel("SponsorBlock Categories")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff; margin: 10px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
desc_label = QLabel(
|
||||
"Select which types of video segments to automatically remove during download.\n"
|
||||
"SponsorBlock uses community-submitted data to identify these segments."
|
||||
)
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Scroll area for categories
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setStyleSheet("QScrollArea { border: none; }")
|
||||
|
||||
scroll_widget = QWidget()
|
||||
scroll_layout = QVBoxLayout(scroll_widget)
|
||||
scroll_layout.setContentsMargins(10, 0, 10, 0)
|
||||
scroll_layout.setSpacing(8)
|
||||
|
||||
# Add category checkboxes
|
||||
for category_id, category_info in self.SPONSORBLOCK_CATEGORIES.items():
|
||||
# Create a container widget for each category
|
||||
category_widget = QWidget()
|
||||
category_layout = QVBoxLayout(category_widget)
|
||||
category_layout.setContentsMargins(0, 0, 0, 0)
|
||||
category_layout.setSpacing(2)
|
||||
|
||||
# Create checkbox with just the name
|
||||
checkbox = QCheckBox(category_info['name'])
|
||||
checkbox.setProperty("category_id", category_id)
|
||||
|
||||
# Determine if this category should be checked
|
||||
if self.previously_selected:
|
||||
# Use previously selected categories
|
||||
is_checked = category_id in self.previously_selected
|
||||
else:
|
||||
# Use default values for first time
|
||||
is_checked = category_info['default']
|
||||
|
||||
checkbox.setChecked(is_checked)
|
||||
|
||||
checkbox.setStyleSheet("""
|
||||
QCheckBox {
|
||||
color: #ffffff;
|
||||
padding: 4px;
|
||||
spacing: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #2b2b2b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #ff0000;
|
||||
background: #ff0000;
|
||||
}
|
||||
""")
|
||||
|
||||
# Create description label
|
||||
desc_label = QLabel(category_info['description'])
|
||||
desc_label.setStyleSheet("color: #aaaaaa; font-size: 11px; margin-left: 28px; margin-bottom: 8px;")
|
||||
desc_label.setWordWrap(True)
|
||||
|
||||
category_layout.addWidget(checkbox)
|
||||
category_layout.addWidget(desc_label)
|
||||
|
||||
self.checkboxes[category_id] = checkbox
|
||||
scroll_layout.addWidget(category_widget)
|
||||
|
||||
scroll_layout.addStretch()
|
||||
scroll_area.setWidget(scroll_widget)
|
||||
layout.addWidget(scroll_area)
|
||||
|
||||
# Quick selection buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
select_defaults_btn = QPushButton("Select Defaults")
|
||||
select_defaults_btn.clicked.connect(self.select_defaults)
|
||||
select_defaults_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
select_all_btn = QPushButton("Select All")
|
||||
select_all_btn.clicked.connect(self.select_all)
|
||||
select_all_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
deselect_all_btn = QPushButton("Deselect All")
|
||||
deselect_all_btn.clicked.connect(self.deselect_all)
|
||||
deselect_all_btn.setStyleSheet(self._get_button_style())
|
||||
|
||||
button_layout.addWidget(select_defaults_btn)
|
||||
button_layout.addWidget(select_all_btn)
|
||||
button_layout.addWidget(deselect_all_btn)
|
||||
button_layout.addStretch()
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Dialog buttons
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
# Style the dialog buttons
|
||||
for button in button_box.buttons():
|
||||
button.setStyleSheet(self._get_button_style())
|
||||
if button_box.buttonRole(button) == QDialogButtonBox.ButtonRole.AcceptRole:
|
||||
button.setStyleSheet(button.styleSheet() +
|
||||
"QPushButton { background-color: #ff0000; border-color: #cc0000; } " +
|
||||
"QPushButton:hover { background-color: #cc0000; }")
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def _get_button_style(self):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""Select all categories."""
|
||||
for checkbox in self.checkboxes.values():
|
||||
checkbox.setChecked(True)
|
||||
|
||||
def deselect_all(self):
|
||||
"""Deselect all categories."""
|
||||
for checkbox in self.checkboxes.values():
|
||||
checkbox.setChecked(False)
|
||||
|
||||
def get_selected_categories(self):
|
||||
"""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):
|
||||
"""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,722 @@
|
||||
"""
|
||||
Settings-related dialogs for YTSage application.
|
||||
Contains dialogs for configuring download settings and auto-update preferences.
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QGroupBox, QCheckBox,
|
||||
QRadioButton, QComboBox, QDialogButtonBox,
|
||||
QButtonGroup, QMessageBox, QFileDialog)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from ...core.ytsage_logging import logger
|
||||
|
||||
from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings,
|
||||
check_and_update_ytdlp_auto, get_ytdlp_version)
|
||||
|
||||
|
||||
class DownloadSettingsDialog(QDialog):
|
||||
def __init__(self, current_path, current_limit, current_unit_index, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Download Settings")
|
||||
self.setMinimumWidth(450)
|
||||
self.setMinimumHeight(400)
|
||||
self.current_path = current_path
|
||||
self.current_limit = current_limit if current_limit is not None else ""
|
||||
self.current_unit_index = current_unit_index
|
||||
|
||||
# Apply main app styling
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QWidget {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QGroupBox {
|
||||
color: #ffffff;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
font-weight: bold;
|
||||
background-color: #15181b;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 10px;
|
||||
padding: 0 5px 0 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
selection-background-color: #c90000;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
color: #999999;
|
||||
}
|
||||
QCheckBox {
|
||||
spacing: 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QCheckBox::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
QRadioButton {
|
||||
spacing: 5px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #15181b;
|
||||
}
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
}
|
||||
QComboBox {
|
||||
padding: 5px;
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #1b2021;
|
||||
color: #ffffff;
|
||||
min-height: 20px;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 20px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
border: 2px solid #1b2021;
|
||||
border-radius: 4px;
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
selection-background-color: #c90000;
|
||||
selection-color: #ffffff;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# --- Download Path Section ---
|
||||
path_group_box = QGroupBox("Download Path")
|
||||
path_layout = QVBoxLayout()
|
||||
|
||||
self.path_display = QLabel(self.current_path)
|
||||
self.path_display.setWordWrap(True)
|
||||
self.path_display.setStyleSheet("QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }")
|
||||
path_layout.addWidget(self.path_display)
|
||||
|
||||
browse_button = QPushButton("Browse...")
|
||||
browse_button.clicked.connect(self.browse_new_path)
|
||||
path_layout.addWidget(browse_button)
|
||||
|
||||
path_group_box.setLayout(path_layout)
|
||||
layout.addWidget(path_group_box)
|
||||
|
||||
# --- Speed Limit Section ---
|
||||
speed_group_box = QGroupBox("Speed Limit")
|
||||
speed_layout = QHBoxLayout()
|
||||
|
||||
self.speed_limit_input = QLineEdit(str(self.current_limit))
|
||||
self.speed_limit_input.setPlaceholderText("None")
|
||||
speed_layout.addWidget(self.speed_limit_input)
|
||||
|
||||
self.speed_limit_unit = QComboBox()
|
||||
self.speed_limit_unit.addItems(["KB/s", "MB/s"])
|
||||
self.speed_limit_unit.setCurrentIndex(self.current_unit_index)
|
||||
speed_layout.addWidget(self.speed_limit_unit)
|
||||
|
||||
speed_group_box.setLayout(speed_layout)
|
||||
layout.addWidget(speed_group_box)
|
||||
|
||||
# --- Auto-Update yt-dlp Section ---
|
||||
auto_update_group_box = QGroupBox("Auto-Update yt-dlp")
|
||||
auto_update_layout = QVBoxLayout()
|
||||
|
||||
# Load current auto-update settings
|
||||
auto_settings = get_auto_update_settings()
|
||||
|
||||
# Enable/Disable auto-update checkbox
|
||||
self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
|
||||
self.auto_update_enabled.setChecked(auto_settings['enabled'])
|
||||
auto_update_layout.addWidget(self.auto_update_enabled)
|
||||
|
||||
# Frequency options
|
||||
frequency_label = QLabel("Update frequency:")
|
||||
frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;")
|
||||
auto_update_layout.addWidget(frequency_label)
|
||||
|
||||
self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
|
||||
self.daily_radio = QRadioButton("Check daily")
|
||||
self.weekly_radio = QRadioButton("Check weekly")
|
||||
|
||||
# Set current selection based on saved settings
|
||||
current_frequency = auto_settings['frequency']
|
||||
if current_frequency == 'startup':
|
||||
self.startup_radio.setChecked(True)
|
||||
elif current_frequency == 'daily':
|
||||
self.daily_radio.setChecked(True)
|
||||
else: # weekly
|
||||
self.weekly_radio.setChecked(True)
|
||||
|
||||
auto_update_layout.addWidget(self.startup_radio)
|
||||
auto_update_layout.addWidget(self.daily_radio)
|
||||
auto_update_layout.addWidget(self.weekly_radio)
|
||||
|
||||
# Test update button
|
||||
test_update_layout = QHBoxLayout()
|
||||
test_update_button = QPushButton("Check for Updates Now")
|
||||
test_update_button.clicked.connect(self.test_update_check)
|
||||
test_update_layout.addWidget(test_update_button)
|
||||
test_update_layout.addStretch()
|
||||
auto_update_layout.addLayout(test_update_layout)
|
||||
|
||||
auto_update_group_box.setLayout(auto_update_layout)
|
||||
layout.addWidget(auto_update_group_box)
|
||||
|
||||
# Dialog buttons (OK/Cancel)
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def browse_new_path(self):
|
||||
new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
|
||||
if new_path:
|
||||
self.current_path = new_path
|
||||
self.path_display.setText(self.current_path)
|
||||
|
||||
def get_selected_path(self):
|
||||
"""Returns the confirmed path after the dialog is accepted."""
|
||||
return self.current_path
|
||||
|
||||
def get_selected_speed_limit(self):
|
||||
"""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):
|
||||
"""Returns the index of the selected speed limit unit."""
|
||||
return self.speed_limit_unit.currentIndex()
|
||||
|
||||
def _create_styled_message_box(self, icon, title, text):
|
||||
"""Create a styled QMessageBox that matches the app theme."""
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(icon)
|
||||
msg_box.setWindowTitle(title)
|
||||
msg_box.setText(text)
|
||||
msg_box.setWindowIcon(self.windowIcon())
|
||||
msg_box.setStyleSheet("""
|
||||
QMessageBox {
|
||||
background-color: #15181b;
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QMessageBox QPushButton:pressed {
|
||||
background-color: #800000;
|
||||
}
|
||||
""")
|
||||
return msg_box
|
||||
|
||||
def test_update_check(self):
|
||||
"""Test the update check functionality."""
|
||||
try:
|
||||
# Get current version
|
||||
current_version = get_ytdlp_version()
|
||||
if "Error" in current_version:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Warning,
|
||||
"Update Check",
|
||||
"Could not determine current yt-dlp version."
|
||||
)
|
||||
msg_box.exec()
|
||||
return
|
||||
|
||||
# Get latest version from PyPI
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace('_', '.')
|
||||
latest_version = latest_version.replace('_', '.')
|
||||
|
||||
from packaging import version as version_parser
|
||||
if version_parser.parse(latest_version) > version_parser.parse(current_version):
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Information,
|
||||
"Update Check",
|
||||
f"Update available!\n\nCurrent: {current_version}\nLatest: {latest_version}\n\nUse the 'Update yt-dlp' button in the main window to update."
|
||||
)
|
||||
msg_box.exec()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Information,
|
||||
"Update Check",
|
||||
f"yt-dlp is up to date!\n\nCurrent version: {current_version}"
|
||||
)
|
||||
msg_box.exec()
|
||||
except Exception as e:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Warning,
|
||||
"Update Check",
|
||||
f"Error checking for updates: {str(e)}"
|
||||
)
|
||||
msg_box.exec()
|
||||
|
||||
def get_auto_update_settings(self):
|
||||
"""Returns the auto-update settings from the dialog."""
|
||||
enabled = self.auto_update_enabled.isChecked()
|
||||
|
||||
if self.startup_radio.isChecked():
|
||||
frequency = 'startup'
|
||||
elif self.daily_radio.isChecked():
|
||||
frequency = 'daily'
|
||||
else: # weekly_radio is checked
|
||||
frequency = 'weekly'
|
||||
|
||||
return enabled, frequency
|
||||
|
||||
def accept(self):
|
||||
"""Override accept to save auto-update settings."""
|
||||
try:
|
||||
# Save auto-update settings
|
||||
enabled, frequency = self.get_auto_update_settings()
|
||||
|
||||
if update_auto_update_settings(enabled, frequency):
|
||||
QMessageBox.information(self, "Settings Saved",
|
||||
"Auto-update settings have been saved successfully!")
|
||||
else:
|
||||
QMessageBox.warning(self, "Error",
|
||||
"Failed to save auto-update settings.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error",
|
||||
f"Error saving auto-update settings: {str(e)}")
|
||||
|
||||
# Call the parent accept method to close the dialog
|
||||
super().accept()
|
||||
|
||||
|
||||
class AutoUpdateSettingsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Auto-Update Settings")
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
# Set the window icon to match the main app
|
||||
if parent:
|
||||
self.setWindowIcon(parent.windowIcon())
|
||||
|
||||
self.init_ui()
|
||||
self.load_current_settings()
|
||||
self.apply_styling()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("<h2>🔄 Auto-Update Settings</h2>")
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel("Configure automatic updates for yt-dlp to ensure you always have the latest features and bug fixes.")
|
||||
desc_label.setWordWrap(True)
|
||||
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
desc_label.setStyleSheet("color: #cccccc; margin: 10px; font-size: 11px;")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
# Enable/Disable auto-update
|
||||
self.enable_checkbox = QCheckBox("Enable automatic yt-dlp updates")
|
||||
self.enable_checkbox.setChecked(True) # Default enabled
|
||||
self.enable_checkbox.toggled.connect(self.on_enable_toggled)
|
||||
layout.addWidget(self.enable_checkbox)
|
||||
|
||||
# Frequency options
|
||||
frequency_group = QGroupBox("Update Frequency")
|
||||
frequency_layout = QVBoxLayout()
|
||||
|
||||
self.frequency_group = QButtonGroup(self)
|
||||
|
||||
self.startup_radio = QRadioButton("Check on every startup (minimum 1 hour between checks)")
|
||||
self.daily_radio = QRadioButton("Check daily")
|
||||
self.weekly_radio = QRadioButton("Check weekly")
|
||||
|
||||
self.daily_radio.setChecked(True) # Default to daily
|
||||
|
||||
self.frequency_group.addButton(self.startup_radio, 0)
|
||||
self.frequency_group.addButton(self.daily_radio, 1)
|
||||
self.frequency_group.addButton(self.weekly_radio, 2)
|
||||
|
||||
frequency_layout.addWidget(self.startup_radio)
|
||||
frequency_layout.addWidget(self.daily_radio)
|
||||
frequency_layout.addWidget(self.weekly_radio)
|
||||
frequency_group.setLayout(frequency_layout)
|
||||
|
||||
layout.addWidget(frequency_group)
|
||||
|
||||
# Current status
|
||||
status_group = QGroupBox("Current Status")
|
||||
status_layout = QVBoxLayout()
|
||||
|
||||
self.current_version_label = QLabel("Current yt-dlp version: Checking...")
|
||||
self.last_check_label = QLabel("Last update check: Never")
|
||||
self.next_check_label = QLabel("Next check: Based on settings")
|
||||
|
||||
status_layout.addWidget(self.current_version_label)
|
||||
status_layout.addWidget(self.last_check_label)
|
||||
status_layout.addWidget(self.next_check_label)
|
||||
status_group.setLayout(status_layout)
|
||||
|
||||
layout.addWidget(status_group)
|
||||
|
||||
# Manual check button
|
||||
self.manual_check_btn = QPushButton("🔍 Check for Updates Now")
|
||||
self.manual_check_btn.clicked.connect(self.manual_check)
|
||||
layout.addWidget(self.manual_check_btn)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.save_btn = QPushButton("Save Settings")
|
||||
self.save_btn.clicked.connect(self.save_settings)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(self.save_btn)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def apply_styling(self):
|
||||
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):
|
||||
"""Load current auto-update settings from config."""
|
||||
try:
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
settings = get_auto_update_settings()
|
||||
|
||||
# Set checkbox
|
||||
self.enable_checkbox.setChecked(settings['enabled'])
|
||||
|
||||
# Set frequency
|
||||
frequency = settings['frequency']
|
||||
if frequency == 'startup':
|
||||
self.startup_radio.setChecked(True)
|
||||
elif frequency == 'weekly':
|
||||
self.weekly_radio.setChecked(True)
|
||||
else: # daily
|
||||
self.daily_radio.setChecked(True)
|
||||
|
||||
# Update status labels
|
||||
current_version = get_ytdlp_version()
|
||||
self.current_version_label.setText(f"Current yt-dlp version: {current_version}")
|
||||
|
||||
last_check = settings['last_check']
|
||||
if last_check > 0:
|
||||
last_check_time = datetime.fromtimestamp(last_check).strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.last_check_label.setText(f"Last update check: {last_check_time}")
|
||||
else:
|
||||
self.last_check_label.setText("Last update check: Never")
|
||||
|
||||
# Calculate next check time
|
||||
self.update_next_check_label()
|
||||
|
||||
# Update UI state
|
||||
self.on_enable_toggled(settings['enabled'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading auto-update settings: {e}")
|
||||
|
||||
def update_next_check_label(self):
|
||||
"""Update the next check label based on current settings."""
|
||||
try:
|
||||
if not self.enable_checkbox.isChecked():
|
||||
self.next_check_label.setText("Next check: Disabled")
|
||||
return
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
settings = get_auto_update_settings()
|
||||
last_check = settings['last_check']
|
||||
frequency = self.get_selected_frequency()
|
||||
|
||||
if last_check == 0:
|
||||
self.next_check_label.setText("Next check: On next startup")
|
||||
return
|
||||
|
||||
next_check_time = last_check
|
||||
if frequency == 'startup':
|
||||
next_check_time += 3600 # 1 hour
|
||||
elif frequency == 'daily':
|
||||
next_check_time += 86400 # 24 hours
|
||||
elif frequency == 'weekly':
|
||||
next_check_time += 604800 # 7 days
|
||||
|
||||
current_time = time.time()
|
||||
if next_check_time <= current_time:
|
||||
self.next_check_label.setText("Next check: Now (overdue)")
|
||||
else:
|
||||
next_check_datetime = datetime.fromtimestamp(next_check_time)
|
||||
self.next_check_label.setText(f"Next check: {next_check_datetime.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
except Exception as e:
|
||||
self.next_check_label.setText("Next check: Error calculating")
|
||||
logger.error(f"Error calculating next check time: {e}")
|
||||
|
||||
def on_enable_toggled(self, enabled):
|
||||
"""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):
|
||||
"""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):
|
||||
"""Perform a manual update check."""
|
||||
self.manual_check_btn.setEnabled(False)
|
||||
self.manual_check_btn.setText("🔄 Checking...")
|
||||
|
||||
# Force an immediate update check
|
||||
def check_in_thread():
|
||||
try:
|
||||
result = check_and_update_ytdlp_auto()
|
||||
|
||||
# Update UI in main thread
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
|
||||
except Exception as e:
|
||||
logger.error(f"Error during manual check: {e}")
|
||||
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
|
||||
|
||||
# Run in separate thread to avoid blocking UI
|
||||
import threading
|
||||
threading.Thread(target=check_in_thread, daemon=True).start()
|
||||
|
||||
def _create_styled_message_box(self, icon, title, text):
|
||||
"""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):
|
||||
"""Handle completion of manual update check."""
|
||||
self.manual_check_btn.setEnabled(True)
|
||||
self.manual_check_btn.setText("🔍 Check for Updates Now")
|
||||
|
||||
if success:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Information,
|
||||
"Update Check",
|
||||
"✅ Update check completed successfully!\nCheck the console for details."
|
||||
)
|
||||
msg_box.exec()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.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):
|
||||
"""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.Information,
|
||||
"Settings Saved",
|
||||
"✅ Auto-update settings have been saved successfully!"
|
||||
)
|
||||
msg_box.exec()
|
||||
self.accept()
|
||||
else:
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Warning,
|
||||
"Error",
|
||||
"❌ Failed to save auto-update settings.\nPlease try again."
|
||||
)
|
||||
msg_box.exec()
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving auto-update settings: {e}")
|
||||
msg_box = self._create_styled_message_box(
|
||||
QMessageBox.Critical,
|
||||
"Error",
|
||||
f"❌ Error saving settings: {str(e)}"
|
||||
)
|
||||
msg_box.exec()
|
||||
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Update-related dialogs and threads for YTSage application.
|
||||
Contains dialogs and background threads for checking and performing yt-dlp updates.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import subprocess
|
||||
import time
|
||||
from packaging import version
|
||||
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QProgressBar, QMessageBox)
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
||||
|
||||
from ...core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ...core.ytsage_utils import get_ytdlp_version, load_config, save_config
|
||||
from ...core.ytsage_logging import logger
|
||||
|
||||
try:
|
||||
import yt_dlp
|
||||
YT_DLP_AVAILABLE = True
|
||||
except ImportError:
|
||||
YT_DLP_AVAILABLE = False
|
||||
|
||||
|
||||
class VersionCheckThread(QThread):
|
||||
finished = Signal(str, str, str) # current_version, latest_version, error_message
|
||||
|
||||
def run(self):
|
||||
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
|
||||
startupinfo=None if sys.platform != 'win32' else subprocess.STARTUPINFO(dwFlags=subprocess.STARTF_USESHOWWINDOW, wShowWindow=subprocess.SW_HIDE),
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' else 0)
|
||||
if result.returncode == 0:
|
||||
current_version = result.stdout.strip()
|
||||
else: # Try fallback if command failed
|
||||
if YT_DLP_AVAILABLE:
|
||||
current_version = yt_dlp.version.__version__
|
||||
else:
|
||||
error_message = "yt-dlp not available."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
# Try fallback if timeout
|
||||
if YT_DLP_AVAILABLE:
|
||||
current_version = yt_dlp.version.__version__
|
||||
else:
|
||||
error_message = "yt-dlp version check timed out and package not found."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
except Exception:
|
||||
# Fallback to importing yt_dlp package directly if subprocess fails
|
||||
if YT_DLP_AVAILABLE:
|
||||
current_version = yt_dlp.version.__version__
|
||||
else:
|
||||
error_message = "yt-dlp not found or accessible."
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
return
|
||||
|
||||
# Get latest version from PyPI
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace('_', '.')
|
||||
latest_version = latest_version.replace('_', '.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_message = f"Network error checking PyPI: {e}"
|
||||
except Exception as e:
|
||||
error_message = f"Error checking version: {e}"
|
||||
|
||||
self.finished.emit(current_version, latest_version, error_message)
|
||||
|
||||
|
||||
class UpdateThread(QThread):
|
||||
update_status = Signal(str) # For status messages
|
||||
update_progress = Signal(int) # For progress percentage (0-100)
|
||||
update_finished = Signal(bool, str) # success (bool), message/error (str)
|
||||
|
||||
def run(self):
|
||||
error_message = ""
|
||||
success = False
|
||||
try:
|
||||
self.update_status.emit("🔍 Checking current installation...")
|
||||
self.update_progress.emit(10)
|
||||
|
||||
# Get the yt-dlp path
|
||||
try:
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
self.update_status.emit(f"📍 Found yt-dlp at: {os.path.basename(yt_dlp_path)}")
|
||||
except Exception as e:
|
||||
self.update_status.emit(f"❌ Error getting yt-dlp path: {e}")
|
||||
self.update_finished.emit(False, f"❌ Error getting yt-dlp path: {e}")
|
||||
return
|
||||
|
||||
# Create startupinfo to hide console on Windows
|
||||
startupinfo = None
|
||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
||||
|
||||
self.update_progress.emit(20)
|
||||
|
||||
# Check if we're using an app-managed binary or system installation
|
||||
app_managed_dirs = [
|
||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
|
||||
os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
|
||||
os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
||||
]
|
||||
|
||||
is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
|
||||
|
||||
if is_app_managed:
|
||||
self.update_status.emit("📦 Updating app-managed yt-dlp binary...")
|
||||
success = self._update_binary(yt_dlp_path)
|
||||
else:
|
||||
self.update_status.emit("🐍 Updating system yt-dlp via pip...")
|
||||
success = self._update_via_pip(startupinfo)
|
||||
|
||||
if success:
|
||||
self.update_progress.emit(100)
|
||||
error_message = "✅ yt-dlp has been successfully updated!"
|
||||
else:
|
||||
error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_message = f"❌ Network error during update: {str(e)}"
|
||||
self.update_status.emit(error_message)
|
||||
success = False
|
||||
except Exception as e:
|
||||
error_message = f"❌ Update failed: {str(e)}"
|
||||
self.update_status.emit(error_message)
|
||||
success = False
|
||||
|
||||
self.update_finished.emit(success, error_message)
|
||||
|
||||
def _update_binary(self, yt_dlp_path):
|
||||
"""Update yt-dlp binary directly from GitHub releases."""
|
||||
try:
|
||||
self.update_status.emit("🌐 Determining download URL...")
|
||||
self.update_progress.emit(30)
|
||||
|
||||
# Determine the URL based on OS
|
||||
if sys.platform == 'win32':
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
elif sys.platform == 'darwin':
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
||||
|
||||
self.update_status.emit("⬇️ Downloading latest yt-dlp binary...")
|
||||
self.update_progress.emit(40)
|
||||
|
||||
# Download with progress tracking and timeout
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
if response.status_code != 200:
|
||||
self.update_status.emit(f"❌ Download failed: HTTP {response.status_code}")
|
||||
return False
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
temp_file = f"{yt_dlp_path}.new"
|
||||
downloaded = 0
|
||||
|
||||
self.update_status.emit("💾 Downloading and saving binary...")
|
||||
|
||||
with open(temp_file, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# Update progress (40-80% for download)
|
||||
if total_size > 0:
|
||||
progress = 40 + int((downloaded / total_size) * 40)
|
||||
self.update_progress.emit(progress)
|
||||
|
||||
self.update_status.emit("🔧 Installing updated binary...")
|
||||
self.update_progress.emit(85)
|
||||
|
||||
# Make executable on Unix systems
|
||||
if sys.platform != 'win32':
|
||||
os.chmod(temp_file, 0o755)
|
||||
|
||||
# Replace the old file with the new one
|
||||
try:
|
||||
# On Windows, we need to remove the old file first
|
||||
if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
|
||||
os.remove(yt_dlp_path)
|
||||
|
||||
os.rename(temp_file, yt_dlp_path)
|
||||
self.update_status.emit("✅ Binary successfully updated!")
|
||||
self.update_progress.emit(95)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.update_status.emit(f"❌ Error installing binary: {e}")
|
||||
# Clean up temp file if it exists
|
||||
if os.path.exists(temp_file):
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.update_status.emit(f"❌ Network error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.update_status.emit(f"❌ Binary update failed: {e}")
|
||||
return False
|
||||
|
||||
def _update_via_pip(self, startupinfo):
|
||||
"""Update yt-dlp via pip."""
|
||||
try:
|
||||
import pkg_resources
|
||||
|
||||
self.update_status.emit("🔍 Checking current pip installation...")
|
||||
self.update_progress.emit(30)
|
||||
|
||||
# Get current version
|
||||
try:
|
||||
current_version = pkg_resources.get_distribution("yt-dlp").version
|
||||
self.update_status.emit(f"📋 Current version: {current_version}")
|
||||
except pkg_resources.DistributionNotFound:
|
||||
self.update_status.emit("⚠️ yt-dlp not found via pip, attempting installation...")
|
||||
current_version = "0.0.0"
|
||||
|
||||
self.update_progress.emit(40)
|
||||
|
||||
# Get the latest version from PyPI
|
||||
self.update_status.emit("🌐 Checking for latest version...")
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
self.update_status.emit("❌ Failed to check for updates")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
latest_version = data["info"]["version"]
|
||||
self.update_status.emit(f"🆕 Latest version: {latest_version}")
|
||||
self.update_progress.emit(50)
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
self.update_status.emit(f"⬆️ Updating from {current_version} to {latest_version}...")
|
||||
self.update_progress.emit(60)
|
||||
|
||||
try:
|
||||
# Run pip update with timeout
|
||||
self.update_status.emit("📦 Running pip install --upgrade...")
|
||||
update_result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=300, # 5 minute timeout for pip install
|
||||
startupinfo=startupinfo
|
||||
)
|
||||
|
||||
self.update_progress.emit(85)
|
||||
|
||||
if update_result.returncode == 0:
|
||||
self.update_status.emit("✅ Pip update completed successfully!")
|
||||
self.update_progress.emit(95)
|
||||
return True
|
||||
else:
|
||||
self.update_status.emit(f"❌ Pip update failed: {update_result.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.update_status.emit("❌ Pip update timed out after 5 minutes")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.update_status.emit(f"❌ Error during pip update: {e}")
|
||||
return False
|
||||
else:
|
||||
self.update_status.emit("✅ yt-dlp is already up to date!")
|
||||
self.update_progress.emit(95)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.update_status.emit(f"❌ Pip update failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class YTDLPUpdateDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update yt-dlp")
|
||||
self.setMinimumWidth(450)
|
||||
self.setMinimumHeight(200)
|
||||
self._closing = False # Flag to track if dialog is closing
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel("Checking for updates...")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setMinimumHeight(60)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress bar
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.hide() # Hide initially
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
self.update_btn = QPushButton("Update")
|
||||
self.update_btn.clicked.connect(self.perform_update)
|
||||
self.update_btn.setEnabled(False)
|
||||
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
|
||||
button_layout.addWidget(self.update_btn)
|
||||
button_layout.addWidget(self.close_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #15181b;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 8px 15px;
|
||||
background-color: #c90000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
min-width: 100px;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #666666;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #a50000;
|
||||
}
|
||||
QProgressBar {
|
||||
border: 2px solid #1d1e22;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background-color: #1d1e22;
|
||||
height: 30px;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 #e60000, stop: 0.5 #ff3333, stop: 1 #c90000);
|
||||
border-radius: 4px;
|
||||
margin: 1px;
|
||||
}
|
||||
""")
|
||||
|
||||
# Start version check in background
|
||||
self.check_version()
|
||||
|
||||
def check_version(self):
|
||||
self.status_label.setText("Checking for updates...")
|
||||
self.update_btn.setEnabled(False)
|
||||
self.version_check_thread = VersionCheckThread()
|
||||
self.version_check_thread.finished.connect(self.on_version_check_finished)
|
||||
self.version_check_thread.start()
|
||||
|
||||
def on_version_check_finished(self, current_version, latest_version, error_message):
|
||||
# Check if dialog is closing to avoid unnecessary updates
|
||||
if hasattr(self, '_closing') and self._closing:
|
||||
return
|
||||
|
||||
if error_message:
|
||||
self.status_label.setText(error_message)
|
||||
self.update_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
if not current_version or not latest_version:
|
||||
self.status_label.setText("Could not determine versions.")
|
||||
self.update_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
try:
|
||||
# Compare versions
|
||||
current_ver = version.parse(current_version)
|
||||
latest_ver = version.parse(latest_version)
|
||||
|
||||
if current_ver < latest_ver:
|
||||
self.status_label.setText(f"Update available!\nCurrent version: {current_version}\nLatest version: {latest_version}")
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
||||
self.update_btn.setEnabled(False)
|
||||
except version.InvalidVersion:
|
||||
# If version parsing fails, do a simple string comparison
|
||||
if current_version != latest_version:
|
||||
self.status_label.setText(f"Update available! (Comparison failed)\nCurrent: {current_version}\nLatest: {latest_version}")
|
||||
self.update_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(f"yt-dlp is up to date (version {current_version})")
|
||||
self.update_btn.setEnabled(False)
|
||||
except Exception as e:
|
||||
self.status_label.setText(f"Error comparing versions: {e}")
|
||||
self.update_btn.setEnabled(False)
|
||||
|
||||
def perform_update(self):
|
||||
# Immediate visual feedback
|
||||
self.update_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(False)
|
||||
self.update_btn.setText("Updating...")
|
||||
self.status_label.setText("🚀 Initializing update process...")
|
||||
|
||||
# Show progress bar immediately
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.show()
|
||||
|
||||
# Start the update thread
|
||||
self._start_update_thread()
|
||||
|
||||
def _start_update_thread(self):
|
||||
"""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):
|
||||
"""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):
|
||||
"""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):
|
||||
"""Slot called when the UpdateThread finishes."""
|
||||
# Check if dialog is closing to avoid unnecessary updates
|
||||
if hasattr(self, '_closing') and self._closing:
|
||||
return
|
||||
|
||||
self.progress_bar.setValue(100)
|
||||
self.status_label.setText(message)
|
||||
self.close_btn.setEnabled(True)
|
||||
self.update_btn.setText("Update") # Reset button text
|
||||
|
||||
if success:
|
||||
# Show success briefly then auto-check version
|
||||
QTimer.singleShot(2000, self.check_version) # Wait 2 seconds then refresh
|
||||
else:
|
||||
# Re-enable update button on failure after a short delay
|
||||
QTimer.singleShot(3000, lambda: self.update_btn.setEnabled(True) if not (hasattr(self, '_closing') and self._closing) else None)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""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):
|
||||
"""Perform automatic yt-dlp update check and update if needed."""
|
||||
try:
|
||||
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
|
||||
|
||||
# Get current version
|
||||
current_version = get_ytdlp_version()
|
||||
if "Error" in current_version:
|
||||
logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
|
||||
self.update_finished.emit(False, "Could not determine current yt-dlp version")
|
||||
return
|
||||
|
||||
# Get latest version from PyPI
|
||||
try:
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
response.raise_for_status()
|
||||
latest_version = response.json()["info"]["version"]
|
||||
|
||||
# Clean up version strings
|
||||
current_version = current_version.replace('_', '.')
|
||||
latest_version = latest_version.replace('_', '.')
|
||||
|
||||
logger.info(f"AutoUpdateThread: Current yt-dlp version: {current_version}")
|
||||
logger.info(f"AutoUpdateThread: Latest yt-dlp version: {latest_version}")
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
logger.info(f"AutoUpdateThread: Auto-updating yt-dlp from {current_version} to {latest_version}...")
|
||||
|
||||
# Perform the update
|
||||
success = self._perform_update()
|
||||
|
||||
if success:
|
||||
logger.info("AutoUpdateThread: Auto-update completed successfully!")
|
||||
# Update the last check timestamp
|
||||
config = load_config()
|
||||
config['last_update_check'] = time.time()
|
||||
save_config(config)
|
||||
self.update_finished.emit(True, f"Successfully updated yt-dlp from {current_version} to {latest_version}")
|
||||
else:
|
||||
logger.warning("AutoUpdateThread: Auto-update failed")
|
||||
self.update_finished.emit(False, "Auto-update failed")
|
||||
else:
|
||||
logger.info("AutoUpdateThread: yt-dlp is already up to date")
|
||||
# Still update the timestamp even if no update was needed
|
||||
config = load_config()
|
||||
config['last_update_check'] = time.time()
|
||||
save_config(config)
|
||||
self.update_finished.emit(True, f"yt-dlp is already up to date (version {current_version})")
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
|
||||
self.update_finished.emit(False, f"Network error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
|
||||
self.update_finished.emit(False, f"Update check error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"AutoUpdateThread: Critical error in auto-update: {e}", exc_info=True)
|
||||
self.update_finished.emit(False, f"Critical error: {e}")
|
||||
|
||||
def _perform_update(self):
|
||||
"""Perform the actual update using similar logic to UpdateThread but without UI feedback."""
|
||||
try:
|
||||
# Get the yt-dlp path
|
||||
yt_dlp_path = get_yt_dlp_path()
|
||||
|
||||
# Check if we're using an app-managed binary or system installation
|
||||
app_managed_dirs = [
|
||||
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'YTSage', 'bin'),
|
||||
os.path.expanduser(os.path.join('~', 'Library', 'Application Support', 'YTSage', 'bin')),
|
||||
os.path.expanduser(os.path.join('~', '.local', 'share', 'YTSage', 'bin'))
|
||||
]
|
||||
|
||||
is_app_managed = any(os.path.dirname(yt_dlp_path) == dir_path for dir_path in app_managed_dirs)
|
||||
|
||||
if is_app_managed:
|
||||
logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...")
|
||||
return self._update_binary(yt_dlp_path)
|
||||
else:
|
||||
logger.info("AutoUpdateThread: Updating system yt-dlp via pip...")
|
||||
return self._update_via_pip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _update_binary(self, yt_dlp_path):
|
||||
"""Update yt-dlp binary directly from GitHub releases (silent version)."""
|
||||
try:
|
||||
# Determine the URL based on OS
|
||||
if sys.platform == 'win32':
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
|
||||
elif sys.platform == 'darwin':
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
|
||||
else:
|
||||
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
|
||||
|
||||
logger.info("AutoUpdateThread: Downloading latest yt-dlp binary...")
|
||||
|
||||
# Download without progress tracking (silent)
|
||||
response = requests.get(url, stream=True)
|
||||
if response.status_code != 200:
|
||||
logger.error(f"AutoUpdateThread: Download failed: HTTP {response.status_code}")
|
||||
return False
|
||||
|
||||
temp_file = f"{yt_dlp_path}.new"
|
||||
|
||||
with open(temp_file, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
logger.info("AutoUpdateThread: Installing updated binary...")
|
||||
|
||||
# Make executable on Unix systems
|
||||
if sys.platform != 'win32':
|
||||
os.chmod(temp_file, 0o755)
|
||||
|
||||
# Replace the old file with the new one
|
||||
try:
|
||||
# On Windows, we need to remove the old file first
|
||||
if sys.platform == 'win32' and os.path.exists(yt_dlp_path):
|
||||
os.remove(yt_dlp_path)
|
||||
|
||||
os.rename(temp_file, yt_dlp_path)
|
||||
logger.info("AutoUpdateThread: Binary successfully updated!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AutoUpdateThread: Error installing binary: {e}")
|
||||
# Clean up temp file if it exists
|
||||
if os.path.exists(temp_file):
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _update_via_pip(self):
|
||||
"""Update yt-dlp via pip (silent version)."""
|
||||
try:
|
||||
import pkg_resources
|
||||
|
||||
logger.info("AutoUpdateThread: Checking current pip installation...")
|
||||
|
||||
# Get current version
|
||||
try:
|
||||
current_version = pkg_resources.get_distribution("yt-dlp").version
|
||||
logger.info(f"AutoUpdateThread: Current version: {current_version}")
|
||||
except pkg_resources.DistributionNotFound:
|
||||
logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...")
|
||||
current_version = "0.0.0"
|
||||
|
||||
# Get the latest version from PyPI
|
||||
logger.info("AutoUpdateThread: Checking for latest version...")
|
||||
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error("AutoUpdateThread: Failed to check for updates")
|
||||
return False
|
||||
|
||||
data = response.json()
|
||||
latest_version = data["info"]["version"]
|
||||
logger.info(f"AutoUpdateThread: Latest version: {latest_version}")
|
||||
|
||||
# Compare versions
|
||||
if version.parse(latest_version) > version.parse(current_version):
|
||||
logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...")
|
||||
|
||||
# Create startupinfo to hide console on Windows
|
||||
startupinfo = None
|
||||
if sys.platform == 'win32' and hasattr(subprocess, 'STARTUPINFO'):
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0 # SW_HIDE
|
||||
|
||||
# Run pip update
|
||||
logger.info("AutoUpdateThread: Running pip install --upgrade...")
|
||||
update_result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
startupinfo=startupinfo
|
||||
)
|
||||
|
||||
if update_result.returncode == 0:
|
||||
logger.info("AutoUpdateThread: Pip update completed successfully!")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"AutoUpdateThread: Pip update failed: {update_result.stderr}")
|
||||
return False
|
||||
else:
|
||||
logger.info("AutoUpdateThread: yt-dlp is already up to date!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
YTSage GUI Dialogs Module
|
||||
|
||||
This module serves as a centralized import point for all dialog classes in YTSage.
|
||||
The dialogs have been split into logical modules for better maintainability:
|
||||
|
||||
- dialogs.ytsage_dialogs_base: Base utility dialogs (LogWindow, AboutDialog)
|
||||
- dialogs.ytsage_dialogs_settings: Settings configuration dialogs
|
||||
- dialogs.ytsage_dialogs_update: Update-related dialogs and threads
|
||||
- dialogs.ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
|
||||
- dialogs.ytsage_dialogs_selection: Subtitle and playlist selection dialogs
|
||||
- dialogs.ytsage_dialogs_custom: Custom functionality dialogs
|
||||
"""
|
||||
|
||||
# Import all dialog classes from the dialogs package
|
||||
from .dialogs import *
|
||||
|
||||
# For backward compatibility, re-export all dialog classes
|
||||
__all__ = [
|
||||
# Base dialogs
|
||||
'LogWindow', 'AboutDialog',
|
||||
|
||||
# Settings dialogs
|
||||
'DownloadSettingsDialog', 'AutoUpdateSettingsDialog',
|
||||
|
||||
# Update dialogs and threads
|
||||
'VersionCheckThread', 'UpdateThread', 'YTDLPUpdateDialog', 'AutoUpdateThread',
|
||||
|
||||
# FFmpeg dialogs
|
||||
'FFmpegInstallThread', 'FFmpegCheckDialog',
|
||||
|
||||
# Selection dialogs
|
||||
'SubtitleSelectionDialog', 'PlaylistSelectionDialog', 'SponsorBlockCategoryDialog',
|
||||
|
||||
# Custom functionality dialogs
|
||||
'CustomCommandDialog', 'CookieLoginDialog', 'CustomOptionsDialog', 'TimeRangeDialog'
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user