Refactor/code cleanup (#37)

* fix imports
remove unused imports
use full import path
sort import (1. Standard Library, 2. Third-Party, 3. Local) in alphabetic.

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

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

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

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

- imporve: return type hint for function.

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

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

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

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

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

* refactor: remove unused imports and streamline code formatting across multiple files
This commit is contained in:
Viren Hirpara
2025-08-26 18:57:12 +05:30
committed by GitHub
parent 6652d7f00f
commit 9c13b4b61c
21 changed files with 2805 additions and 2628 deletions
-41
View File
@@ -1,41 +0,0 @@
"""
Dialog modules for YTSage GUI.
This package contains all dialog classes organized by functionality:
- Base dialogs (LogWindow, AboutDialog)
- Settings dialogs (DownloadSettingsDialog, AutoUpdateSettingsDialog)
- Update dialogs (YTDLPUpdateDialog, update threads)
- FFmpeg dialogs (FFmpegCheckDialog, installation)
- Selection dialogs (SubtitleSelectionDialog, PlaylistSelectionDialog)
- Custom dialogs (CustomCommandDialog, CookieLoginDialog, etc.)
"""
# Re-export all dialog classes for backward compatibility
from .ytsage_dialogs_base import LogWindow, AboutDialog
from .ytsage_dialogs_settings import DownloadSettingsDialog, AutoUpdateSettingsDialog
from .ytsage_dialogs_update import (VersionCheckThread, UpdateThread, YTDLPUpdateDialog,
AutoUpdateThread)
from .ytsage_dialogs_ffmpeg import FFmpegInstallThread, FFmpegCheckDialog
from .ytsage_dialogs_selection import SubtitleSelectionDialog, PlaylistSelectionDialog, SponsorBlockCategoryDialog
from .ytsage_dialogs_custom import (CustomCommandDialog, CookieLoginDialog,
CustomOptionsDialog, TimeRangeDialog)
__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'
]
-37
View File
@@ -1,37 +0,0 @@
"""
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'
]
+60
View File
@@ -0,0 +1,60 @@
"""
Dialog modules for YTSage GUI.
This package contains all dialog classes organized by functionality:
- ytsage_dialogs_base: Base utility dialogs
- ytsage_dialogs_settings: Settings configuration dialogs
- ytsage_dialogs_update: Update-related dialogs and threads
- ytsage_dialogs_ffmpeg: FFmpeg installation dialogs
- ytsage_dialogs_selection: Subtitle and playlist selection dialogs
- ytsage_dialogs_custom: Custom functionality dialogs
"""
# Re-export all dialog classes for backward compatibility
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import (
CookieLoginDialog,
CustomCommandDialog,
CustomOptionsDialog,
TimeRangeDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
PlaylistSelectionDialog,
SponsorBlockCategoryDialog,
SubtitleSelectionDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_settings import AutoUpdateSettingsDialog, DownloadSettingsDialog
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_update import AutoUpdateThread, UpdateThread, VersionCheckThread, YTDLPUpdateDialog
__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",
]
@@ -3,32 +3,37 @@ 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 PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QTextEdit,
QVBoxLayout,
QWidget,
)
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)
from src.core.ytsage_ffmpeg import get_ffmpeg_path
from src.core.ytsage_utils import _version_cache, check_ffmpeg, get_ffmpeg_version, get_ytdlp_version, refresh_version_cache
from src.core.ytsage_yt_dlp import check_ytdlp_installed, get_yt_dlp_path
class LogWindow(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('yt-dlp Log')
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("""
self.log_text.setStyleSheet(
"""
QTextEdit {
background-color: #2b2b2b;
color: #ffffff;
@@ -37,11 +42,12 @@ class LogWindow(QDialog):
border: 2px solid #3d3d3d;
border-radius: 4px;
}
""")
"""
)
layout.addWidget(self.log_text)
def append_log(self, message):
def append_log(self, message) -> None:
self.log_text.append(message)
# Auto-scroll to bottom
scrollbar = self.log_text.verticalScrollBar()
@@ -49,17 +55,17 @@ class LogWindow(QDialog):
class AboutDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent # Store parent to access version etc.
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
@@ -84,7 +90,7 @@ class AboutDialog(QDialog):
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)
@@ -92,7 +98,8 @@ class AboutDialog(QDialog):
layout.addLayout(button_layout)
# Apply overall styling - improved consistency
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -131,20 +138,25 @@ class AboutDialog(QDialog):
color: #ffffff;
font-size: 14px;
}
""")
"""
)
def _create_app_info_section(self):
def _create_app_info_section(self) -> QWidget:
"""Create the application information section - compact version"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setSpacing(6) # Reduced spacing
# Title and Version - more compact
title_label = QLabel("<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>")
title_label = 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 = 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)
@@ -158,49 +170,56 @@ class AboutDialog(QDialog):
# 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 = 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 = 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):
def _create_system_info_section(self) -> QWidget:
"""Create the system information section with compact design"""
# Create main container with compact styling
container = QWidget()
container.setStyleSheet("""
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("""
title_label.setStyleSheet(
"""
QLabel {
color: #ffffff;
font-size: 14px;
@@ -208,16 +227,18 @@ class AboutDialog(QDialog):
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("""
self.refresh_btn.setStyleSheet(
"""
QPushButton {
padding: 0px;
background-color: transparent;
@@ -236,99 +257,103 @@ class AboutDialog(QDialog):
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):
def _show_loading_message(self) -> None:
"""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("""
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=""):
def _create_status_item(self, icon, name, status_text, version_text, path_text=None, cache_status="") -> QWidget:
"""Create a compact status item widget"""
item_widget = QWidget()
# Adjust height based on whether we have path info
item_height = 50 if path_text else 35
item_widget.setMaximumHeight(item_height)
item_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
# Main layout
item_layout = QVBoxLayout(item_widget)
item_layout.setContentsMargins(8, 4, 8, 4)
item_layout.setSpacing(2)
# First row: Icon, name, status, version
first_row = QHBoxLayout()
first_row.setSpacing(8)
# Icon and name - compact
name_label = QLabel(f"{icon} <b>{name}</b>")
name_label.setStyleSheet("font-size: 12px; color: #ffffff; font-weight: bold;")
name_label.setMinimumWidth(80)
first_row.addWidget(name_label)
# Status - compact
status_label = QLabel(status_text)
status_label.setStyleSheet("font-size: 11px; font-weight: bold;")
status_label.setMinimumWidth(70)
first_row.addWidget(status_label)
# Version info - improved readability
version_info = version_text
if cache_status:
version_info += cache_status
version_label = QLabel(version_info)
version_label.setStyleSheet("font-size: 11px; color: #cccccc;") # Increased from 10px
version_label.setWordWrap(False)
first_row.addWidget(version_label)
# Add stretch to push everything left
first_row.addStretch()
item_layout.addLayout(first_row)
# Second row: Path (if provided)
if path_text:
path_label = QLabel(f"📁 {path_text}")
path_label.setStyleSheet("font-size: 10px; color: #aaaaaa; margin-left: 12px;") # Increased from 9px, better color
path_label.setWordWrap(False)
# Truncate very long paths
if len(path_text) > 60:
truncated_path = "..." + path_text[-57:]
if len(str(path_text)) > 60:
truncated_path = "..." + str(path_text)[-57:]
path_label.setText(f"📁 {truncated_path}")
item_layout.addWidget(path_label)
# Subtle background with minimal border
item_widget.setStyleSheet("""
item_widget.setStyleSheet(
"""
QWidget {
background-color: rgba(45, 45, 45, 0.3);
border: 1px solid #2a2a2a;
@@ -338,11 +363,12 @@ class AboutDialog(QDialog):
QWidget:hover {
background-color: rgba(60, 60, 60, 0.4);
}
""")
"""
)
return item_widget
def update_system_info(self):
def update_system_info(self) -> None:
"""Update the system information display with compact layout."""
# Clear existing items
for i in reversed(range(self.status_container.count())):
@@ -352,86 +378,101 @@ class AboutDialog(QDialog):
# 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_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)
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
"🎥",
"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_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)
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
"🎬",
"FFmpeg",
ffmpeg_status_text,
ffmpeg_version + cache_status,
ffmpeg_path_text,
)
self.status_container.addWidget(ffmpeg_item)
def refresh_version_info(self):
def refresh_version_info(self) -> None:
"""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):
def on_refresh_finished(self, success) -> None:
"""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.setIcon(QMessageBox.Icon.Warning)
msg_box.setWindowTitle("Refresh Failed")
msg_box.setText("Failed to refresh version information.")
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -451,5 +492,6 @@ class AboutDialog(QDialog):
QMessageBox QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
msg_box.exec()
@@ -3,30 +3,47 @@ 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
import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
from ...core.ytsage_yt_dlp import get_yt_dlp_path
from PySide6.QtCore import Q_ARG, QMetaObject, Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPlainTextEdit,
QPushButton,
QTabWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from src.core.ytsage_yt_dlp import get_yt_dlp_path
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
class CustomCommandDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Custom yt-dlp Command')
self._parent = self.parent()
self.setWindowTitle("Custom yt-dlp Command")
self.setMinimumSize(600, 400)
layout = QVBoxLayout(self)
@@ -44,7 +61,8 @@ class CustomCommandDialog(QDialog):
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText("Enter yt-dlp arguments...")
self.command_input.setStyleSheet("""
self.command_input.setStyleSheet(
"""
QPlainTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -53,12 +71,14 @@ class CustomCommandDialog(QDialog):
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("""
self.sponsorblock_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -79,7 +99,8 @@ class CustomCommandDialog(QDialog):
background: #c90000;
border-radius: 9px;
}
""")
"""
)
layout.insertWidget(layout.indexOf(self.command_input), self.sponsorblock_checkbox)
# Buttons
@@ -98,7 +119,8 @@ class CustomCommandDialog(QDialog):
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
self.log_output.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -108,10 +130,12 @@ class CustomCommandDialog(QDialog):
font-family: Consolas, monospace;
font-size: 12px;
}
""")
"""
)
layout.addWidget(self.log_output)
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -126,35 +150,38 @@ class CustomCommandDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
def run_custom_command(self):
url = self.parent.url_input.text().strip()
def run_custom_command(self) -> None:
url = self._parent.url_input.text().strip() # type: ignore[reportAttributeAccessIssue]
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()
path = self._parent.path_input.text().strip() # type: ignore[reportAttributeAccessIssue]
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()
threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start()
def _run_command_thread(self, command, url, path):
def _run_command_thread(self, command, url, path) -> None:
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
@@ -163,34 +190,43 @@ class CustomCommandDialog(QDialog):
# Base options
ydl_opts = {
'logger': CommandLogger(self),
'paths': {'home': path},
'debug_printout': True,
'postprocessors': []
"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
}])
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('-', '_')
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'
if value.lower() in ("true", "false"):
value = value.lower() == "true"
elif value.isdigit():
value = int(value)
ydl_opts[key] = value
@@ -209,9 +245,9 @@ class CustomCommandDialog(QDialog):
class CookieLoginDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('Login with Cookies')
self.setWindowTitle("Login with Cookies")
self.setMinimumSize(400, 150)
layout = QVBoxLayout(self)
@@ -237,43 +273,38 @@ class CookieLoginDialog(QDialog):
layout.addLayout(path_layout)
# Dialog buttons
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.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_cookie_file(self):
def browse_cookie_file(self) -> None:
# 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])
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
if selected_files:
self.cookie_path_input.setText(selected_files[0])
def get_cookie_file_path(self):
def get_cookie_file_path(self) -> str:
# Return the selected cookie file path
return self.cookie_path_input.text()
class CustomOptionsDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Custom Options')
self._parent: YTSageApp = cast("YTSageApp", self.parent()) # cast will help with auto complete and type hint checking.
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"
@@ -287,26 +318,26 @@ class CustomOptionsDialog(QDialog):
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)
if hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
self.cookie_path_input.setText(self._parent.cookie_file_path.as_posix())
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"
@@ -319,7 +350,8 @@ class CustomOptionsDialog(QDialog):
# Add SponsorBlock checkbox
self.sponsorblock_checkbox = QCheckBox("Remove Sponsor Segments")
self.sponsorblock_checkbox.setStyleSheet("""
self.sponsorblock_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -340,13 +372,15 @@ class CustomOptionsDialog(QDialog):
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("""
self.command_input.setStyleSheet(
"""
QPlainTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -355,7 +389,8 @@ class CustomOptionsDialog(QDialog):
padding: 8px;
font-family: Consolas, monospace;
}
""")
"""
)
command_layout.addWidget(self.command_input)
# Run command button
@@ -366,7 +401,8 @@ class CustomOptionsDialog(QDialog):
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet("""
self.log_output.setStyleSheet(
"""
QTextEdit {
background-color: #1d1e22;
color: #ffffff;
@@ -376,21 +412,23 @@ class CustomOptionsDialog(QDialog):
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 = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply global styles
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -434,65 +472,71 @@ class CustomOptionsDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
def browse_cookie_file(self):
def browse_cookie_file(self) -> None:
# 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;")
selected_files, _ = QFileDialog.getOpenFileName(self, "Select Cookie File", "", "Cookies files (*.txt *.lwp)")
def get_cookie_file_path(self):
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) -> Path | None:
# 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):
path = Path(self.cookie_path_input.text().strip())
if path and path.exists():
return path
return None
def run_custom_command(self):
url = self.parent.url_input.text().strip()
def run_custom_command(self) -> None:
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
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()
threading.Thread(target=self._run_command_thread, args=(command, url, path), daemon=True).start()
def _run_command_thread(self, command, url, path):
def _run_command_thread(self, command, url, path) -> None:
try:
class CommandLogger:
def debug(self, msg):
QMetaObject.invokeMethod(
self.dialog.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, msg)
self.dialog.log_output,
b"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}")
self.dialog.log_output,
b"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}")
self.dialog.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Error: {msg}"),
)
def __init__(self, dialog):
self.dialog = dialog
@@ -504,12 +548,14 @@ class CustomOptionsDialog(QDialog):
base_cmd = [yt_dlp_path] + args + [url]
if self.sponsorblock_checkbox.isChecked():
base_cmd.extend(['--sponsorblock-remove', 'sponsor,selfpromo,interaction'])
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)}")
self.log_output,
b"append",
Qt.ConnectionType.QueuedConnection,
Q_ARG(str, f"Full command: {' '.join(base_cmd)}"),
)
# Run the command
@@ -518,50 +564,59 @@ class CustomOptionsDialog(QDialog):
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace'
encoding="utf-8",
errors="replace",
)
# Stream output
for line in proc.stdout:
for line in proc.stdout: # type: ignore[reportOptionalIterable]
QMetaObject.invokeMethod(
self.log_output, "append", Qt.ConnectionType.QueuedConnection,
Q_ARG(str, line.rstrip())
self.log_output,
b"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}")
self.log_output,
b"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")
self.log_output,
b"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)}")
self.log_output,
b"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)
self.run_btn,
b"setEnabled",
Qt.ConnectionType.QueuedConnection,
Q_ARG(bool, True),
)
class TimeRangeDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.parent = parent
self.setWindowTitle('Download Video Section')
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"
@@ -570,11 +625,11 @@ class TimeRangeDialog(QDialog):
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:"))
@@ -582,7 +637,7 @@ class TimeRangeDialog(QDialog):
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:"))
@@ -590,14 +645,15 @@ class TimeRangeDialog(QDialog):
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("""
self.force_keyframes.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -617,14 +673,16 @@ class TimeRangeDialog(QDialog):
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("""
self.preview_label = QLabel('--download-sections "*-"')
self.preview_label.setStyleSheet(
"""
QLabel {
background-color: #1d1e22;
color: #ffffff;
@@ -633,24 +691,26 @@ class TimeRangeDialog(QDialog):
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 = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
# Apply styling
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -687,15 +747,16 @@ class TimeRangeDialog(QDialog):
QPushButton:hover {
background-color: #a50000;
}
""")
"""
)
# Initialize preview
self.update_preview()
def update_preview(self):
def update_preview(self) -> None:
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:
@@ -704,21 +765,21 @@ class TimeRangeDialog(QDialog):
time_range = f"*-{end}"
else:
time_range = "*-" # Full video
preview = f"--download-sections \"{time_range}\""
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):
def get_download_sections(self) -> str | None:
"""Returns the download sections command arguments or None if no selection made"""
start = self.start_time_input.text().strip()
end = self.end_time_input.text().strip()
if not start and not end:
return None # No selection made
if start and end:
time_range = f"*{start}-{end}"
elif start:
@@ -727,9 +788,9 @@ class TimeRangeDialog(QDialog):
time_range = f"*-{end}"
else:
return None # Shouldn't happen but just in case
return time_range
def get_force_keyframes(self):
def get_force_keyframes(self) -> bool:
"""Returns whether to force keyframes at cuts"""
return self.force_keyframes.isChecked()
@@ -3,57 +3,52 @@ FFmpeg installation dialogs for YTSage application.
Contains dialogs and threads for checking and installing FFmpeg.
"""
import sys
import os
import webbrowser
import contextlib
import webbrowser
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
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from src.core.ytsage_ffmpeg import auto_install_ffmpeg, check_ffmpeg_installed
from src.utils.ytsage_constants import ICON_PATH
class FFmpegInstallThread(QThread):
finished = Signal(bool)
progress = Signal(str)
def run(self):
def run(self) -> None:
# 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):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle('FFmpeg Installation')
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))
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
layout = QVBoxLayout(self)
layout.setSpacing(15)
@@ -66,10 +61,7 @@ class FFmpegCheckDialog(QDialog):
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 = 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)
@@ -80,7 +72,8 @@ class FFmpegCheckDialog(QDialog):
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("""
self.progress_label.setStyleSheet(
"""
QLabel {
background-color: #1d1e22;
color: #cccccc;
@@ -91,17 +84,18 @@ class FFmpegCheckDialog(QDialog):
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)
@@ -109,7 +103,7 @@ class FFmpegCheckDialog(QDialog):
# Manual install button
self.manual_btn = QPushButton("Manual Guide")
self.manual_btn.clicked.connect(lambda: webbrowser.open('https://github.com/oop7/ffmpeg-install-guide'))
self.manual_btn.clicked.connect(lambda: webbrowser.open("https://github.com/oop7/ffmpeg-install-guide"))
button_layout.addWidget(self.manual_btn)
# Close button
@@ -120,7 +114,8 @@ class FFmpegCheckDialog(QDialog):
layout.addLayout(button_layout)
# Style the dialog to match app theme
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -148,16 +143,17 @@ class FFmpegCheckDialog(QDialog):
background-color: #666666;
color: #999999;
}
""")
"""
)
# Initialize installation thread
self.install_thread = None
def start_installation(self):
def start_installation(self) -> None:
self.install_btn.setEnabled(False)
self.manual_btn.setEnabled(False)
self.close_btn.setEnabled(False)
# Check if FFmpeg is already installed
if check_ffmpeg_installed():
self.message_label.setText("FFmpeg is already installed!")
@@ -167,7 +163,7 @@ class FFmpegCheckDialog(QDialog):
self.manual_btn.hide()
self.close_btn.setEnabled(True)
return
self.message_label.setText("Installing FFmpeg... Please wait")
self.progress_label.show()
@@ -176,10 +172,10 @@ class FFmpegCheckDialog(QDialog):
self.install_thread.progress.connect(self.update_progress)
self.install_thread.start()
def update_progress(self, message):
def update_progress(self, message) -> None:
self.progress_label.setText(message)
def installation_finished(self, success):
def installation_finished(self, success) -> None:
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.")
@@ -190,5 +186,5 @@ class FFmpegCheckDialog(QDialog):
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)
@@ -3,14 +3,23 @@ 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
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
class SubtitleSelectionDialog(QDialog):
def __init__(self, available_manual, available_auto, previously_selected, parent=None):
def __init__(self, available_manual, available_auto, previously_selected, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Select Subtitles")
self.setMinimumWidth(400)
@@ -28,7 +37,8 @@ class SubtitleSelectionDialog(QDialog):
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("""
self.filter_input.setStyleSheet(
"""
QLineEdit {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -40,7 +50,8 @@ class SubtitleSelectionDialog(QDialog):
QLineEdit:focus {
border-color: #ff0000;
}
""")
"""
)
layout.addWidget(self.filter_input)
# Scroll Area for the list
@@ -67,7 +78,8 @@ class SubtitleSelectionDialog(QDialog):
# Style the buttons
for button in button_box.buttons():
button.setStyleSheet("""
button.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -82,14 +94,18 @@ class SubtitleSelectionDialog(QDialog):
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; }")
"""
)
# 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=""):
def populate_list(self, filter_text="") -> None:
# Clear existing checkboxes from layout
while self.list_layout.count():
item = self.list_layout.takeAt(0)
@@ -102,14 +118,14 @@ class SubtitleSelectionDialog(QDialog):
# 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"
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 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 ""))
@@ -126,7 +142,8 @@ class SubtitleSelectionDialog(QDialog):
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("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -144,15 +161,16 @@ class SubtitleSelectionDialog(QDialog):
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):
def filter_list(self) -> None:
self.populate_list(self.filter_input.text())
def update_selection(self, state):
def update_selection(self, state) -> None:
sender = self.sender()
subtitle_id = sender.property("subtitle_id")
if state == Qt.CheckState.Checked.value:
@@ -162,18 +180,18 @@ class SubtitleSelectionDialog(QDialog):
if subtitle_id in self.previously_selected:
self.previously_selected.remove(subtitle_id)
def get_selected_subtitles(self):
def get_selected_subtitles(self) -> list:
# Return the final set as a list
return list(self.previously_selected)
def accept(self):
def accept(self) -> None:
# Update the final list before closing
self.selected_subtitles = self.get_selected_subtitles()
super().accept()
class PlaylistSelectionDialog(QDialog):
def __init__(self, playlist_entries, previously_selected_string, parent=None):
def __init__(self, playlist_entries, previously_selected_string, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Select Playlist Videos")
self.setMinimumWidth(500)
@@ -192,7 +210,8 @@ class PlaylistSelectionDialog(QDialog):
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("""
select_all_btn.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -207,7 +226,8 @@ class PlaylistSelectionDialog(QDialog):
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)
@@ -233,10 +253,11 @@ class PlaylistSelectionDialog(QDialog):
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("""
button.setStyleSheet(
"""
QPushButton {
background-color: #363636;
border: 2px solid #3d3d3d;
@@ -251,15 +272,20 @@ class PlaylistSelectionDialog(QDialog):
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; }")
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("""
self.setStyleSheet(
"""
QDialog { background-color: #15181b; }
QCheckBox {
color: #ffffff;
@@ -279,21 +305,22 @@ class PlaylistSelectionDialog(QDialog):
background: #ff0000;
}
QWidget { background-color: #15181b; }
""")
"""
)
def _parse_selection_string(self, selection_string):
def _parse_selection_string(self, selection_string) -> set:
"""Parses a yt-dlp playlist selection string (e.g., '1-3,5,7-9') into a set of 1-based indices."""
selected_indices = set()
if not selection_string:
# If no previous selection, assume all are selected initially
return set(range(1, len(self.playlist_entries) + 1))
parts = selection_string.split(',')
parts = selection_string.split(",")
for part in parts:
part = part.strip()
if '-' in part:
if "-" in part:
try:
start, end = map(int, part.split('-'))
start, end = map(int, part.split("-"))
if start <= end:
selected_indices.update(range(start, end + 1))
except ValueError:
@@ -305,10 +332,10 @@ class PlaylistSelectionDialog(QDialog):
pass # Ignore invalid numbers
return selected_indices
def _populate_list(self, previously_selected_string):
def _populate_list(self, previously_selected_string) -> None:
"""Populates the scroll area with checkboxes for each video."""
selected_indices = self._parse_selection_string(previously_selected_string)
# Clear existing checkboxes if any (e.g., if repopulating)
while self.list_layout.count():
child = self.list_layout.takeAt(0)
@@ -317,18 +344,19 @@ class PlaylistSelectionDialog(QDialog):
self.checkboxes.clear()
for index, entry in enumerate(self.playlist_entries):
if not entry:
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}')
title = entry.get("title", f"Video {video_index}")
# Shorten title if too long
display_title = (title[:70] + '...') if len(title) > 73 else title
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("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 5px;
@@ -346,132 +374,126 @@ class PlaylistSelectionDialog(QDialog):
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):
def _select_all(self) -> None:
for checkbox in self.checkboxes:
checkbox.setChecked(True)
def _deselect_all(self):
def _deselect_all(self) -> None:
for checkbox in self.checkboxes:
checkbox.setChecked(False)
def _condense_indices(self, indices):
def _condense_indices(self, indices: list[int]) -> str:
"""Condenses a list of 1-based indices into a yt-dlp selection string."""
if not indices:
return ""
indices = sorted(list(set(indices)))
if not indices: # Check again after sorting/set conversion
return ""
# Remove duplicates and sort in one step
indices = sorted(set(indices))
ranges = []
start = indices[0]
end = indices[0]
for i in range(1, len(indices)):
if indices[i] == end + 1:
end = indices[i]
start = end = indices[0]
for num in indices[1:]:
if num == end + 1:
end = num
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}")
ranges.append(f"{start}-{end}" if start != end else str(start))
start = end = num
# Append the last range
ranges.append(f"{start}-{end}" if start != end else str(start))
return ",".join(ranges)
def get_selected_items_string(self):
def get_selected_items_string(self) -> str | None:
"""Returns the selection string based on checked boxes."""
selected_indices = [
cb.property("video_index") for cb in self.checkboxes if cb.isChecked()
]
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 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
"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
"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
"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
"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
"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
"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
"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,
},
'filler': {
'name': 'Filler Tangent',
'description': 'Tangential scenes added only for filler or humor',
'default': False
}
}
def __init__(self, previously_selected=None, parent=None):
def __init__(self, previously_selected=None, parent=None) -> 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):
def init_ui(self) -> None:
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."
@@ -480,17 +502,17 @@ class SponsorBlockCategoryDialog(QDialog):
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
@@ -498,22 +520,23 @@ class SponsorBlockCategoryDialog(QDialog):
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 = 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']
is_checked = category_info["default"]
checkbox.setChecked(is_checked)
checkbox.setStyleSheet("""
checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
padding: 4px;
@@ -533,61 +556,64 @@ class SponsorBlockCategoryDialog(QDialog):
border: 2px solid #ff0000;
background: #ff0000;
}
""")
"""
)
# Create description label
desc_label = QLabel(category_info['description'])
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; }")
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):
def _get_button_style(self) -> str:
"""Returns the standard button style for this dialog."""
return """
QPushButton {
@@ -605,10 +631,11 @@ class SponsorBlockCategoryDialog(QDialog):
background-color: #555555;
}
"""
def apply_styling(self):
def apply_styling(self) -> None:
"""Apply the dialog styling to match the rest of the application."""
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -619,33 +646,34 @@ class SponsorBlockCategoryDialog(QDialog):
QWidget {
background-color: #15181b;
}
""")
def select_defaults(self):
"""
)
def select_defaults(self) -> None:
"""Select only the default categories."""
for category_id, checkbox in self.checkboxes.items():
default_value = self.SPONSORBLOCK_CATEGORIES[category_id]['default']
default_value = self.SPONSORBLOCK_CATEGORIES[category_id]["default"]
checkbox.setChecked(default_value)
def select_all(self):
def select_all(self) -> None:
"""Select all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(True)
def deselect_all(self):
def deselect_all(self) -> None:
"""Deselect all categories."""
for checkbox in self.checkboxes.values():
checkbox.setChecked(False)
def get_selected_categories(self):
def get_selected_categories(self) -> list:
"""Returns a list of selected category IDs."""
selected = []
for category_id, checkbox in self.checkboxes.items():
if checkbox.isChecked():
selected.append(category_id)
return selected
def get_selected_categories_string(self):
def get_selected_categories_string(self) -> str:
"""Returns a comma-separated string of selected categories for yt-dlp."""
selected = self.get_selected_categories()
return ','.join(selected) if selected else ''
return ",".join(selected) if selected else ""
@@ -3,22 +3,39 @@ 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
import time
from datetime import datetime
from ...core.ytsage_utils import (get_auto_update_settings, update_auto_update_settings,
check_and_update_ytdlp_auto, get_ytdlp_version)
import requests
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QRadioButton,
QVBoxLayout,
)
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import (
check_and_update_ytdlp_auto,
get_auto_update_settings,
get_ytdlp_version,
update_auto_update_settings,
)
class DownloadSettingsDialog(QDialog):
def __init__(self, current_path, current_limit, current_unit_index, parent=None):
def __init__(self, current_path, current_limit, current_unit_index, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("Download Settings")
self.setMinimumWidth(450)
@@ -28,7 +45,8 @@ class DownloadSettingsDialog(QDialog):
self.current_unit_index = current_unit_index
# Apply main app styling
self.setStyleSheet("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -137,7 +155,8 @@ class DownloadSettingsDialog(QDialog):
selection-background-color: #c90000;
selection-color: #ffffff;
}
""")
"""
)
layout = QVBoxLayout(self)
@@ -147,7 +166,9 @@ class DownloadSettingsDialog(QDialog):
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; }")
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...")
@@ -182,7 +203,7 @@ class DownloadSettingsDialog(QDialog):
# Enable/Disable auto-update checkbox
self.auto_update_enabled = QCheckBox("Enable automatic yt-dlp updates")
self.auto_update_enabled.setChecked(auto_settings['enabled'])
self.auto_update_enabled.setChecked(auto_settings["enabled"])
auto_update_layout.addWidget(self.auto_update_enabled)
# Frequency options
@@ -195,10 +216,10 @@ class DownloadSettingsDialog(QDialog):
self.weekly_radio = QRadioButton("Check weekly")
# Set current selection based on saved settings
current_frequency = auto_settings['frequency']
if current_frequency == 'startup':
current_frequency = auto_settings["frequency"]
if current_frequency == "startup":
self.startup_radio.setChecked(True)
elif current_frequency == 'daily':
elif current_frequency == "daily":
self.daily_radio.setChecked(True)
else: # weekly
self.weekly_radio.setChecked(True)
@@ -224,17 +245,17 @@ class DownloadSettingsDialog(QDialog):
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
def browse_new_path(self):
def browse_new_path(self) -> None:
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):
def get_selected_path(self) -> str:
"""Returns the confirmed path after the dialog is accepted."""
return self.current_path
def get_selected_speed_limit(self):
def get_selected_speed_limit(self) -> str | None:
"""Returns the entered speed limit value (as string or None)."""
limit_str = self.speed_limit_input.text().strip()
if not limit_str:
@@ -246,18 +267,19 @@ class DownloadSettingsDialog(QDialog):
logger.info("Invalid speed limit input in dialog")
return None
def get_selected_unit_index(self):
def get_selected_unit_index(self) -> int:
"""Returns the index of the selected speed limit unit."""
return self.speed_limit_unit.currentIndex()
def _create_styled_message_box(self, icon, title, text):
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
"""Create a styled QMessageBox that matches the app theme."""
msg_box = QMessageBox(self)
msg_box.setIcon(icon)
msg_box.setWindowTitle(title)
msg_box.setText(text)
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -280,183 +302,187 @@ class DownloadSettingsDialog(QDialog):
QMessageBox QPushButton:pressed {
background-color: #800000;
}
""")
"""
)
return msg_box
def test_update_check(self):
def test_update_check(self) -> None:
"""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,
QMessageBox.Icon.Warning,
"Update Check",
"Could not determine current yt-dlp version."
"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('_', '.')
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,
QMessageBox.Icon.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."
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,
QMessageBox.Icon.Information,
"Update Check",
f"yt-dlp is up to date!\n\nCurrent version: {current_version}"
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,
QMessageBox.Icon.Warning,
"Update Check",
f"Error checking for updates: {str(e)}"
f"Error checking for updates: {str(e)}",
)
msg_box.exec()
def get_auto_update_settings(self):
def get_auto_update_settings(self) -> tuple[bool, str]:
"""Returns the auto-update settings from the dialog."""
enabled = self.auto_update_enabled.isChecked()
if self.startup_radio.isChecked():
frequency = 'startup'
frequency = "startup"
elif self.daily_radio.isChecked():
frequency = 'daily'
frequency = "daily"
else: # weekly_radio is checked
frequency = 'weekly'
frequency = "weekly"
return enabled, frequency
def accept(self):
def accept(self) -> None:
"""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!")
QMessageBox.information(
self,
"Settings Saved",
"Auto-update settings have been saved successfully!",
)
else:
QMessageBox.warning(self, "Error",
"Failed to save auto-update settings.")
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)}")
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):
def __init__(self, parent=None) -> 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):
def init_ui(self) -> None:
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("""
def apply_styling(self) -> None:
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
color: #ffffff;
@@ -518,24 +544,22 @@ class AutoUpdateSettingsDialog(QDialog):
background-color: #666666;
color: #999999;
}
""")
def load_current_settings(self):
"""
)
def load_current_settings(self) -> None:
"""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'])
self.enable_checkbox.setChecked(settings["enabled"])
# Set frequency
frequency = settings['frequency']
if frequency == 'startup':
frequency = settings["frequency"]
if frequency == "startup":
self.startup_radio.setChecked(True)
elif frequency == 'weekly':
elif frequency == "weekly":
self.weekly_radio.setChecked(True)
else: # daily
self.daily_radio.setChecked(True)
@@ -543,106 +567,106 @@ class AutoUpdateSettingsDialog(QDialog):
# 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']
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'])
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):
def update_next_check_label(self) -> None:
"""Update the next check label based on current settings."""
try:
if not self.enable_checkbox.isChecked():
self.next_check_label.setText("Next check: Disabled")
return
import time
from datetime import datetime, timedelta
settings = get_auto_update_settings()
last_check = settings['last_check']
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':
if frequency == "startup":
next_check_time += 3600 # 1 hour
elif frequency == 'daily':
next_check_time += 86400 # 24 hours
elif frequency == 'weekly':
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):
def on_enable_toggled(self, enabled) -> None:
"""Handle enable/disable checkbox toggle."""
# Enable/disable frequency options
for i in range(self.frequency_group.buttons().__len__()):
self.frequency_group.button(i).setEnabled(enabled)
self.update_next_check_label()
def get_selected_frequency(self):
def get_selected_frequency(self) -> str:
"""Get the selected frequency setting."""
if self.startup_radio.isChecked():
return 'startup'
return "startup"
elif self.weekly_radio.isChecked():
return 'weekly'
return "weekly"
else:
return 'daily'
def manual_check(self):
return "daily"
def manual_check(self) -> None:
"""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():
def check_in_thread() -> None:
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):
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
"""Create a styled QMessageBox that matches the app theme."""
msg_box = QMessageBox(self)
msg_box.setIcon(icon)
msg_box.setWindowTitle(title)
msg_box.setText(text)
msg_box.setWindowIcon(self.windowIcon())
msg_box.setStyleSheet("""
msg_box.setStyleSheet(
"""
QMessageBox {
background-color: #15181b;
color: #ffffff;
@@ -665,58 +689,55 @@ class AutoUpdateSettingsDialog(QDialog):
QMessageBox QPushButton:pressed {
background-color: #800000;
}
""")
"""
)
return msg_box
def manual_check_finished(self, success):
def manual_check_finished(self, success) -> None:
"""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,
QMessageBox.Icon.Information,
"Update Check",
"✅ Update check completed successfully!\nCheck the console for details."
"✅ 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."
QMessageBox.Icon.Warning,
"Update Check",
"❌ Update check failed.\nCheck the console for error details.",
)
msg_box.exec()
# Refresh the current settings display
self.load_current_settings()
def save_settings(self):
def save_settings(self) -> None:
"""Save the auto-update settings."""
try:
enabled = self.enable_checkbox.isChecked()
frequency = self.get_selected_frequency()
if update_auto_update_settings(enabled, frequency):
msg_box = self._create_styled_message_box(
QMessageBox.Information,
QMessageBox.Icon.Information,
"Settings Saved",
"✅ Auto-update settings have been saved successfully!"
"✅ Auto-update settings have been saved successfully!",
)
msg_box.exec()
self.accept()
else:
msg_box = self._create_styled_message_box(
QMessageBox.Warning,
QMessageBox.Icon.Warning,
"Error",
"❌ Failed to save auto-update settings.\nPlease try again."
"❌ 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 = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
msg_box.exec()
@@ -3,22 +3,25 @@ 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 sys
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 pathlib import Path
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
import requests
from packaging import version
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
@@ -26,29 +29,30 @@ except ImportError:
class VersionCheckThread(QThread):
finished = Signal(str, str, str) # current_version, latest_version, error_message
def run(self):
def run(self) -> None:
current_version = ""
latest_version = ""
error_message = ""
try:
# Get the yt-dlp executable path
yt_dlp_path = get_yt_dlp_path()
# Get current version with timeout
try:
result = subprocess.run([yt_dlp_path, '--version'],
capture_output=True,
text=True,
timeout=30, # 30 second timeout
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)
result = subprocess.run(
[yt_dlp_path, "--version"],
capture_output=True,
text=True,
timeout=30, # 30 second timeout
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
current_version = result.stdout.strip()
else: # Try fallback if command failed
if YT_DLP_AVAILABLE:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
else:
error_message = "yt-dlp not available."
self.finished.emit(current_version, latest_version, error_message)
@@ -56,34 +60,34 @@ class VersionCheckThread(QThread):
except subprocess.TimeoutExpired:
# Try fallback if timeout
if YT_DLP_AVAILABLE:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
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
# Fallback to importing yt_dlp package directly if subprocess fails
if YT_DLP_AVAILABLE:
current_version = yt_dlp.version.__version__
current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue]
else:
error_message = "yt-dlp not found or accessible."
self.finished.emit(current_version, latest_version, error_message)
return
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('_', '.')
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)
@@ -91,54 +95,43 @@ 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):
def run(self) -> None:
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)}")
self.update_status.emit(f"📍 Found yt-dlp at: {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
# Extra logic moved to src\utils\ytsage_constants.py
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)
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
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)
success = self._update_via_pip()
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)
@@ -147,92 +140,56 @@ class UpdateThread(QThread):
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."""
def _update_binary(self, yt_dlp_path: Path) -> bool:
"""Update yt-dlp binary using its built-in updater (same logic as AutoUpdateThread)."""
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)
logger.info("UpdateThread: Checking for yt-dlp updates...")
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
logger.info("UpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
self.update_status.emit("✅ 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
else:
logger.error(f"UpdateThread: yt-dlp update failed. {result.stderr.strip()}")
self.update_status.emit(f"yt-dlp update failed: {result.stderr.strip()}")
return False
except requests.RequestException as e:
self.update_status.emit(f"❌ Network error: {e}")
except subprocess.TimeoutExpired:
logger.error("UpdateThread: yt-dlp update timed out.")
self.update_status.emit("❌ yt-dlp update timed out.")
return False
except Exception as e:
self.update_status.emit(f"❌ Binary update failed: {e}")
logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True)
self.update_status.emit(f"❌ Unexpected error during update: {e}")
return False
def _update_via_pip(self, startupinfo):
def _update_via_pip(self) -> bool:
"""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
@@ -240,27 +197,27 @@ class UpdateThread(QThread):
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...")
@@ -270,11 +227,11 @@ class UpdateThread(QThread):
text=True,
check=False,
timeout=300, # 5 minute timeout for pip install
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
self.update_progress.emit(85)
if update_result.returncode == 0:
self.update_status.emit("✅ Pip update completed successfully!")
self.update_progress.emit(95)
@@ -282,7 +239,7 @@ class UpdateThread(QThread):
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
@@ -293,49 +250,50 @@ class UpdateThread(QThread):
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):
def __init__(self, parent=None) -> 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("""
self.setStyleSheet(
"""
QDialog {
background-color: #15181b;
}
@@ -375,40 +333,43 @@ class YTDLPUpdateDialog(QDialog):
border-radius: 4px;
margin: 1px;
}
""")
"""
)
# Start version check in background
self.check_version()
def check_version(self):
def check_version(self) -> None:
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):
def on_version_check_finished(self, current_version, latest_version, error_message) -> None:
# Check if dialog is closing to avoid unnecessary updates
if hasattr(self, '_closing') and self._closing:
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
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.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})")
@@ -416,31 +377,33 @@ class YTDLPUpdateDialog(QDialog):
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.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)
self.status_label.setText(f"Error comparing versions: {e}")
self.update_btn.setEnabled(False)
def perform_update(self):
def perform_update(self) -> None:
# 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):
def _start_update_thread(self) -> None:
"""Start the actual update thread."""
# Create and start the update thread
self.update_thread = UpdateThread()
@@ -449,95 +412,102 @@ class YTDLPUpdateDialog(QDialog):
self.update_thread.update_finished.connect(self.on_update_finished)
self.update_thread.start()
def on_update_status(self, message):
def on_update_status(self, message) -> None:
"""Slot to receive status messages from UpdateThread."""
if not (hasattr(self, '_closing') and self._closing):
if not (hasattr(self, "_closing") and self._closing):
self.status_label.setText(message)
def on_update_progress(self, progress):
def on_update_progress(self, progress) -> None:
"""Slot to receive progress updates from UpdateThread."""
if not (hasattr(self, '_closing') and self._closing):
if not (hasattr(self, "_closing") and self._closing):
self.progress_bar.setValue(progress)
def on_update_finished(self, success, message):
def on_update_finished(self, success, message) -> None:
"""Slot called when the UpdateThread finishes."""
# Check if dialog is closing to avoid unnecessary updates
if hasattr(self, '_closing') and self._closing:
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)
QTimer.singleShot(
3000,
lambda: (self.update_btn.setEnabled(True) if not (hasattr(self, "_closing") and self._closing) else None),
)
def closeEvent(self, event):
def closeEvent(self, event) -> None:
"""Ensure threads are terminated if the dialog is closed prematurely."""
# Set a flag to indicate dialog is closing
self._closing = True
if hasattr(self, 'version_check_thread') and self.version_check_thread.isRunning():
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():
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):
def run(self) -> None:
"""Perform automatic yt-dlp update check and update if needed."""
try:
logger.info("AutoUpdateThread: Performing automatic yt-dlp update check...")
# Get current version
current_version = get_ytdlp_version()
if "Error" in current_version:
logger.warning("AutoUpdateThread: Could not determine current yt-dlp version, skipping auto-update")
self.update_finished.emit(False, "Could not determine current yt-dlp version")
return
# Get latest version from PyPI
try:
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
response.raise_for_status()
latest_version = response.json()["info"]["version"]
# Clean up version strings
current_version = current_version.replace('_', '.')
latest_version = latest_version.replace('_', '.')
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()
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}")
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")
@@ -545,110 +515,89 @@ class AutoUpdateThread(QThread):
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()
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})")
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)
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):
def _perform_update(self) -> bool:
"""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)
# Extra logic moved to src\utils\ytsage_constants.py
is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH)
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)."""
def _update_binary(self, yt_dlp_path: Path) -> bool:
"""Update yt-dlp binary using its built-in updater."""
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!")
logger.info("AutoUpdateThread: Checking for yt-dlp updates...")
result = subprocess.run(
[yt_dlp_path, "-U"],
capture_output=True,
text=True,
timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if result.returncode == 0:
# Make executable on Unix systems
if OS_NAME != "Windows":
os.chmod(yt_dlp_path, 0o755)
logger.info("AutoUpdateThread: yt-dlp update completed successfully.")
if result.stdout:
logger.debug(f"yt-dlp output: {result.stdout.strip()}")
return True
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
else:
logger.error(f"AutoUpdateThread: yt-dlp update failed. {result.stderr.strip()}")
return False
except Exception as e:
logger.error(f"AutoUpdateThread: Binary update failed: {e}", exc_info=True)
except subprocess.TimeoutExpired:
logger.error("AutoUpdateThread: yt-dlp update timed out.")
return False
def _update_via_pip(self):
except Exception as e:
logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True)
return False
def _update_via_pip(self) -> bool:
"""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
@@ -656,30 +605,25 @@ class AutoUpdateThread(QThread):
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
# Extra logic moved to src\utils\ytsage_constants.py
# Run pip update
logger.info("AutoUpdateThread: Running pip install --upgrade...")
update_result = subprocess.run(
@@ -687,9 +631,9 @@ class AutoUpdateThread(QThread):
capture_output=True,
text=True,
check=False,
startupinfo=startupinfo
creationflags=SUBPROCESS_CREATIONFLAGS,
)
if update_result.returncode == 0:
logger.info("AutoUpdateThread: Pip update completed successfully!")
return True
@@ -699,7 +643,7 @@ class AutoUpdateThread(QThread):
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
+136 -112
View File
@@ -1,58 +1,67 @@
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit,
QDialog, QPlainTextEdit, QCheckBox, QButtonGroup, QScrollArea,
QSizePolicy)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
class FormatSignals(QObject):
format_update = Signal(list)
class FormatTableMixin:
def setup_format_table(self):
def setup_format_table(self) -> QTableWidget:
self.format_signals = FormatSignals()
# Format table with improved styling
self.format_table = QTableWidget()
self.format_table.setColumnCount(8)
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
self.format_table.setHorizontalHeaderLabels(
[
"Select",
"Quality",
"Extension",
"Resolution",
"File Size",
"Codec",
"Audio",
"Notes",
]
)
# Enable alternating row colors
self.format_table.setAlternatingRowColors(True)
# Set specific column widths and resize modes
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Select
self.format_table.setColumnWidth(0, 50) # Select column width
self.format_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) # Quality
self.format_table.setColumnWidth(1, 100) # Quality width
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Extension
self.format_table.setColumnWidth(2, 80) # Extension width
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Resolution
self.format_table.setColumnWidth(3, 100) # Resolution width
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # File Size
self.format_table.setColumnWidth(4, 100) # File Size width
self.format_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Codec
self.format_table.setColumnWidth(5, 150) # Codec width
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Audio
self.format_table.setColumnWidth(6, 120) # Audio width
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) # Notes (will stretch)
# Set vertical header (row numbers) visible to false
self.format_table.verticalHeader().setVisible(False)
# Set selection mode to no selection (since we're using checkboxes)
self.format_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
self.format_table.setStyleSheet("""
self.format_table.setStyleSheet(
"""
QTableWidget {
background-color: #1b2021;
border: 2px solid #1b2021;
@@ -96,27 +105,28 @@ class FormatTableMixin:
QWidget {
background-color: transparent;
}
""")
"""
)
# Store format checkboxes and formats
self.format_checkboxes = []
self.all_formats = []
# Set table size policies
self.format_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Set minimum and maximum heights
self.format_table.setMinimumHeight(200)
# Connect the signal
self.format_signals.format_update.connect(self._update_format_table)
return self.format_table
def filter_formats(self):
if not hasattr(self, 'all_formats'):
def filter_formats(self) -> None:
if not hasattr(self, "all_formats"):
return
# Clear current table
self.format_table.setRowCount(0)
self.format_checkboxes.clear()
@@ -124,44 +134,46 @@ class FormatTableMixin:
# Determine which formats to show
filtered_formats = []
if hasattr(self, 'video_button') and self.video_button.isChecked():
filtered_formats.extend([f for f in self.all_formats
if f.get('vcodec') != 'none'
and f.get('filesize') is not None])
if hasattr(self, "video_button") and self.video_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
filtered_formats.extend([f for f in self.all_formats if f.get("vcodec") != "none" and f.get("filesize") is not None])
if hasattr(self, 'audio_button') and self.audio_button.isChecked():
filtered_formats.extend([f for f in self.all_formats
if (f.get('vcodec') == 'none'
or 'audio only' in f.get('format_note', '').lower())
and f.get('acodec') != 'none'
and f.get('filesize') is not None])
if hasattr(self, "audio_button") and self.audio_button.isChecked(): # type: ignore[reportAttributeAccessIssue]
filtered_formats.extend(
[
f
for f in self.all_formats
if (f.get("vcodec") == "none" or "audio only" in f.get("format_note", "").lower())
and f.get("acodec") != "none"
and f.get("filesize") is not None
]
)
# Sort formats by quality
def get_quality(f):
if f.get('vcodec') != 'none':
res = f.get('resolution', '0x0').split('x')[-1]
if f.get("vcodec") != "none":
res = f.get("resolution", "0x0").split("x")[-1]
try:
return int(res)
except ValueError:
return 0
else:
return f.get('abr', 0)
return f.get("abr", 0)
filtered_formats.sort(key=get_quality, reverse=True)
# Update table with filtered formats
self.format_signals.format_update.emit(filtered_formats)
def _update_format_table(self, formats):
def _update_format_table(self, formats) -> None:
self.format_table.setRowCount(0)
self.format_checkboxes.clear()
is_playlist_mode = hasattr(self, 'is_playlist') and self.is_playlist
is_playlist_mode = hasattr(self, "is_playlist") and self.is_playlist # type: ignore[reportAttributeAccessIssue]
# Configure columns based on mode
if is_playlist_mode:
self.format_table.setColumnCount(5)
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Resolution', 'Notes', 'Audio'])
self.format_table.setHorizontalHeaderLabels(["Select", "Quality", "Resolution", "Notes", "Audio"])
# Configure column visibility and resizing for playlist mode
self.format_table.setColumnHidden(5, True)
@@ -175,14 +187,25 @@ class FormatTableMixin:
self.format_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.format_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.format_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
else:
self.format_table.setColumnCount(8)
self.format_table.setHorizontalHeaderLabels(['Select', 'Quality', 'Extension', 'Resolution', 'File Size', 'Codec', 'Audio', 'Notes'])
self.format_table.setHorizontalHeaderLabels(
[
"Select",
"Quality",
"Extension",
"Resolution",
"File Size",
"Codec",
"Audio",
"Notes",
]
)
# Ensure all columns are visible
for i in range(2, 8):
self.format_table.setColumnHidden(i, False)
self.format_table.setColumnHidden(i, False)
# Reapply resize modes for non-playlist mode if needed (optional, might be okay without)
self.format_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.format_table.setColumnWidth(0, 50)
@@ -199,20 +222,22 @@ class FormatTableMixin:
self.format_table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
self.format_table.setColumnWidth(6, 120)
self.format_table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch)
# Find best quality format for recommendations (only needed for non-playlist mode notes)
best_video_size = 0
if not is_playlist_mode:
best_video_size = max((f.get('filesize', 0) for f in formats if f.get('vcodec') != 'none'), default=0)
best_video_size = max(
(f.get("filesize", 0) for f in formats if f.get("vcodec") != "none"),
default=0,
)
for f in formats:
row = self.format_table.rowCount()
self.format_table.insertRow(row)
# Column 0: Select Checkbox (Always shown)
checkbox = QCheckBox()
checkbox.format_id = str(f.get('format_id', ''))
checkbox.format_id = str(f.get("format_id", "")) # type: ignore[reportAttributeAccessIssue]
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
self.format_checkboxes.append(checkbox)
checkbox_widget = QWidget()
@@ -229,84 +254,83 @@ class FormatTableMixin:
quality_item = QTableWidgetItem(quality_text)
# Set color based on quality
if "Best" in quality_text:
quality_item.setForeground(QColor('#00ff00')) # Green for best quality
quality_item.setForeground(QColor("#00ff00")) # Green for best quality
elif "High" in quality_text:
quality_item.setForeground(QColor('#00cc00')) # Light green for high quality
quality_item.setForeground(QColor("#00cc00")) # Light green for high quality
elif "Medium" in quality_text:
quality_item.setForeground(QColor('#ffaa00')) # Orange for medium quality
quality_item.setForeground(QColor("#ffaa00")) # Orange for medium quality
elif "Low" in quality_text:
quality_item.setForeground(QColor('#ff5555')) # Red for low quality
quality_item.setForeground(QColor("#ff5555")) # Red for low quality
self.format_table.setItem(row, 1, quality_item)
# --- Populate columns common to both modes (Moved outside the 'if not is_playlist_mode' block) ---
# Column 2: Resolution (Always shown)
resolution = f.get('resolution', 'N/A')
if f.get('vcodec') == 'none':
resolution = 'Audio only'
resolution = f.get("resolution", "N/A")
if f.get("vcodec") == "none":
resolution = "Audio only"
self.format_table.setItem(row, 2, QTableWidgetItem(resolution))
# Column 3: Notes for playlist mode, Extension for normal mode
if is_playlist_mode:
# Get notes for playlist mode
notes = self._get_format_notes(f)
notes_item = QTableWidgetItem(notes)
if "✨ Recommended" in notes:
notes_item.setForeground(QColor('#00ff00')) # Green for recommended
notes_item.setForeground(QColor("#00ff00")) # Green for recommended
elif "💾 Storage friendly" in notes:
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
elif "📱 Mobile friendly" in notes:
notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
notes_item.setForeground(QColor("#ff9900")) # Orange for mobile
self.format_table.setItem(row, 3, notes_item)
else:
# Extension for normal mode (column 2)
self.format_table.setItem(row, 2, QTableWidgetItem(f.get('ext', '').upper()))
self.format_table.setItem(row, 2, QTableWidgetItem(f.get("ext", "").upper()))
# Column 4 in playlist mode, Column 6 in normal mode: Audio Status
needs_audio = f.get('acodec') == 'none' and f.get('vcodec') != 'none' # Only mark video-only as needing merge
audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get('vcodec') != 'none' else "Audio Only")
needs_audio = f.get("acodec") == "none" and f.get("vcodec") != "none" # Only mark video-only as needing merge
audio_status = "Will merge audio" if needs_audio else ("✓ Has Audio" if f.get("vcodec") != "none" else "Audio Only")
audio_item = QTableWidgetItem(audio_status)
if needs_audio:
audio_item.setForeground(QColor('#ffa500'))
audio_item.setForeground(QColor("#ffa500"))
elif audio_status == "Audio Only":
audio_item.setForeground(QColor('#cccccc')) # Neutral color for audio only
else: # Has Audio (Video+Audio)
audio_item.setForeground(QColor('#00cc00')) # Green for included audio
audio_item.setForeground(QColor("#cccccc")) # Neutral color for audio only
else: # Has Audio (Video+Audio)
audio_item.setForeground(QColor("#00cc00")) # Green for included audio
# Set item for correct column based on mode
audio_column_index = 4 if is_playlist_mode else 6
self.format_table.setItem(row, audio_column_index, audio_item)
# --- Populate columns only shown in non-playlist mode ---
if not is_playlist_mode:
# Column 3: Resolution
self.format_table.setItem(row, 3, QTableWidgetItem(resolution))
# Column 4: File Size
filesize = f"{f.get('filesize', 0) / 1024 / 1024:.2f} MB"
self.format_table.setItem(row, 4, QTableWidgetItem(filesize))
# Column 5: Codec
if f.get('vcodec') == 'none':
codec = f.get('acodec', 'N/A')
if f.get("vcodec") == "none":
codec = f.get("acodec", "N/A")
else:
codec = f"{f.get('vcodec', 'N/A')}"
if f.get('acodec') != 'none':
if f.get("acodec") != "none":
codec += f" / {f.get('acodec', 'N/A')}"
self.format_table.setItem(row, 5, QTableWidgetItem(codec))
# Column 7: Notes
notes = self._get_format_notes(f)
notes_item = QTableWidgetItem(notes)
if "✨ Recommended" in notes:
notes_item.setForeground(QColor('#00ff00')) # Green for recommended
notes_item.setForeground(QColor("#00ff00")) # Green for recommended
elif "💾 Storage friendly" in notes:
notes_item.setForeground(QColor('#00ccff')) # Blue for storage friendly
notes_item.setForeground(QColor("#00ccff")) # Blue for storage friendly
elif "📱 Mobile friendly" in notes:
notes_item.setForeground(QColor('#ff9900')) # Orange for mobile
notes_item.setForeground(QColor("#ff9900")) # Orange for mobile
self.format_table.setItem(row, 7, notes_item)
def handle_checkbox_click(self, clicked_checkbox):
def handle_checkbox_click(self, clicked_checkbox) -> None:
for checkbox in self.format_checkboxes:
if checkbox != clicked_checkbox:
checkbox.setChecked(False)
@@ -317,15 +341,15 @@ class FormatTableMixin:
return checkbox.format_id
return None
def update_format_table(self, formats):
def update_format_table(self, formats) -> None:
self.all_formats = formats
self.format_signals.format_update.emit(formats)
def get_quality_label(self, format_info):
def get_quality_label(self, format_info) -> str:
"""Determine quality label based on format information"""
if format_info.get('vcodec') == 'none':
if format_info.get("vcodec") == "none":
# Audio quality
abr = format_info.get('abr', 0)
abr = format_info.get("abr", 0)
if abr >= 256:
return "Best Audio"
elif abr >= 192:
@@ -337,13 +361,13 @@ class FormatTableMixin:
else:
# Video quality
height = 0
resolution = format_info.get('resolution', '')
resolution = format_info.get("resolution", "")
if resolution:
try:
height = int(resolution.split('x')[1])
height = int(resolution.split("x")[1])
except:
pass
if height >= 2160:
return "Best (4K)"
elif height >= 1440:
@@ -357,20 +381,20 @@ class FormatTableMixin:
else:
return "Low Quality"
def _get_format_notes(self, format_info):
def _get_format_notes(self, format_info) -> str:
"""Generate helpful format notes based on format info."""
notes = []
# Add storage indicator with more granular categories
file_size = format_info.get('filesize') or format_info.get('filesize_approx', 0)
resolution = format_info.get('resolution', '')
file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0)
resolution = format_info.get("resolution", "")
height = 0
if resolution:
try:
height = int(resolution.split('x')[1])
height = int(resolution.split("x")[1])
except:
pass
# Better file size categories
if file_size > 50 * 1024 * 1024: # Over 50MB
notes.append("Large size")
@@ -380,20 +404,20 @@ class FormatTableMixin:
notes.append("Standard size")
else: # Under 5MB
notes.append("Small size")
# Add codec quality indicator
vcodec = format_info.get('vcodec', '')
if vcodec != 'none':
if 'avc1' in vcodec: # H.264
vcodec = format_info.get("vcodec", "")
if vcodec != "none":
if "avc1" in vcodec: # H.264
notes.append("Compatible")
elif 'av01' in vcodec: # AV1
elif "av01" in vcodec: # AV1
notes.append("Efficient")
elif 'vp9' in vcodec: # VP9
elif "vp9" in vcodec: # VP9
notes.append("High quality")
# Add quick mobile compatibility check
if 'avc1' in vcodec and file_size < 8 * 1024 * 1024:
if "avc1" in vcodec and file_size < 8 * 1024 * 1024:
notes.append("Mobile")
# Return simple string
return "".join(notes)
return "".join(notes)
+495 -459
View File
File diff suppressed because it is too large Load Diff
+114 -112
View File
@@ -1,32 +1,24 @@
import sys
import os
import webbrowser
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLineEdit, QPushButton, QTableWidget,
QTableWidgetItem, QProgressBar, QLabel, QFileDialog,
QHeaderView, QStyle, QStyleFactory, QComboBox, QTextEdit, QDialog, QPlainTextEdit, QCheckBox, QButtonGroup)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QIcon, QPalette, QColor, QPixmap
import requests
from io import BytesIO
from PIL import Image
from datetime import datetime
import json
from pathlib import Path
from packaging import version
import subprocess
import re
from ..core.ytsage_logging import logger
try:
import yt_dlp
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
from .ytsage_gui_dialogs import SubtitleSelectionDialog, SponsorBlockCategoryDialog
from datetime import datetime
from io import BytesIO
from pathlib import Path
import requests
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget
from yt_dlp import YoutubeDL
from src.core.ytsage_logging import logger
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
SponsorBlockCategoryDialog,
SubtitleSelectionDialog,
)
class VideoInfoMixin:
def setup_video_info_section(self):
def setup_video_info_section(self) -> QHBoxLayout:
# Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout()
media_info_layout.setSpacing(15)
@@ -36,7 +28,7 @@ class VideoInfoMixin:
thumbnail_container.setFixedWidth(320)
thumbnail_layout = QVBoxLayout(thumbnail_container)
thumbnail_layout.setContentsMargins(0, 0, 0, 0)
# Thumbnail on the left
self.thumbnail_label = QLabel()
self.thumbnail_label.setFixedSize(320, 180)
@@ -44,14 +36,14 @@ class VideoInfoMixin:
self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
thumbnail_layout.addWidget(self.thumbnail_label)
thumbnail_layout.addStretch()
media_info_layout.addWidget(thumbnail_container)
# Video information on the right
video_info_layout = QVBoxLayout()
video_info_layout.setSpacing(2) # Reduce spacing between elements
video_info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Title and info labels
self.title_label = QLabel()
self.title_label.setWordWrap(True)
@@ -65,14 +57,22 @@ class VideoInfoMixin:
self.like_count_label = QLabel()
# Style the info labels
for label in [self.channel_label, self.views_label, self.date_label, self.duration_label, self.like_count_label]:
label.setStyleSheet("""
for label in [
self.channel_label,
self.views_label,
self.date_label,
self.duration_label,
self.like_count_label,
]:
label.setStyleSheet(
"""
QLabel {
color: #cccccc;
font-size: 12px;
padding: 1px 0;
}
""")
"""
)
# Add labels to video info layout
video_info_layout.addWidget(self.title_label)
@@ -90,11 +90,12 @@ class VideoInfoMixin:
subtitle_layout.setSpacing(10)
# Subtitle selection button
self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text
self.subtitle_select_btn = QPushButton("Select Subtitles...") # Renamed & changed text
self.subtitle_select_btn.setFixedHeight(30)
# self.subtitle_select_btn.setFixedWidth(150) # Let it size naturally or adjust as needed
self.subtitle_select_btn.clicked.connect(self.open_subtitle_dialog)
self.subtitle_select_btn.setStyleSheet("""
self.subtitle_select_btn.setStyleSheet(
"""
QPushButton {
background-color: #1d1e22;
border: 2px solid #1d1e22;
@@ -112,8 +113,9 @@ class VideoInfoMixin:
color: #888888;
border-color: #3d3d3d;
}
""")
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
"""
)
self.subtitle_select_btn.setProperty("subtitlesSelected", False) # Custom property for styling
subtitle_layout.addWidget(self.subtitle_select_btn)
# Label to show number of selected subtitles
@@ -130,11 +132,12 @@ class VideoInfoMixin:
# --- SponsorBlock Section ---
sponsorblock_layout = QHBoxLayout()
self.sponsorblock_select_btn = QPushButton("SponsorBlock Categories...")
self.sponsorblock_select_btn.setFixedHeight(30)
self.sponsorblock_select_btn.clicked.connect(self.open_sponsorblock_dialog)
self.sponsorblock_select_btn.setStyleSheet("""
self.sponsorblock_select_btn.setStyleSheet(
"""
QPushButton {
background-color: #1d1e22;
border: 2px solid #1d1e22;
@@ -152,18 +155,19 @@ class VideoInfoMixin:
color: #888888;
border-color: #3d3d3d;
}
""")
"""
)
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", False)
sponsorblock_layout.addWidget(self.sponsorblock_select_btn)
# Label to show selection count
self.selected_sponsorblock_label = QLabel("0 selected")
self.selected_sponsorblock_label.setStyleSheet("color: #cccccc; padding-left: 5px;")
sponsorblock_layout.addWidget(self.selected_sponsorblock_label)
sponsorblock_layout.addStretch()
# Add the sponsorblock layout to the main video info layout
video_info_layout.addLayout(sponsorblock_layout)
# --- End SponsorBlock Section ---
@@ -180,10 +184,11 @@ class VideoInfoMixin:
return media_info_layout
def setup_playlist_info_section(self):
def setup_playlist_info_section(self) -> QLabel:
self.playlist_info_label = QLabel()
self.playlist_info_label.setVisible(False)
self.playlist_info_label.setStyleSheet("""
self.playlist_info_label.setStyleSheet(
"""
QLabel {
font-size: 12px;
color: #ffffff;
@@ -195,18 +200,19 @@ class VideoInfoMixin:
min-height: 30px;
max-height: 30px;
}
""")
"""
)
self.playlist_info_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
return self.playlist_info_label
def update_video_info(self, info):
if hasattr(self, 'is_playlist') and self.is_playlist:
def update_video_info(self, info) -> None:
if hasattr(self, "is_playlist") and self.is_playlist:
# Playlist Mode: Show playlist title and video count
self.title_label.setText(self.playlist_info.get('title', 'Unknown Playlist'))
num_videos = len(getattr(self, 'playlist_entries', []))
self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist"))
num_videos = len(getattr(self, "playlist_entries", []))
self.duration_label.setText(f"Total Videos: {num_videos}")
# Hide video-specific info
self.channel_label.setText("")
self.views_label.setText("")
@@ -225,63 +231,62 @@ class VideoInfoMixin:
self.like_count_label.setVisible(True)
# Format view count with commas
views = info.get('view_count')
formatted_views = f"{views:,}" if views is not None else 'N/A'
views = info.get("view_count")
formatted_views = f"{views:,}" if views is not None else "N/A"
# Format like count with commas
likes = info.get('like_count')
formatted_likes = f"{likes:,}" if likes is not None else 'N/A'
likes = info.get("like_count")
formatted_likes = f"{likes:,}" if likes is not None else "N/A"
# Format upload date
upload_date = info.get('upload_date', '')
upload_date = info.get("upload_date", "")
if upload_date:
date_obj = datetime.strptime(upload_date, '%Y%m%d')
formatted_date = date_obj.strftime('%B %d, %Y')
date_obj = datetime.strptime(upload_date, "%Y%m%d")
formatted_date = date_obj.strftime("%B %d, %Y")
else:
formatted_date = 'Unknown date'
formatted_date = "Unknown date"
# Format duration
duration = info.get('duration', 0)
duration = info.get("duration", 0)
minutes = duration // 60
seconds = duration % 60
duration_str = f"{minutes}:{seconds:02d}"
# Update labels
self.title_label.setText(info.get('title', 'Unknown title'))
self.title_label.setText(info.get("title", "Unknown title"))
self.channel_label.setText(f"Channel: {info.get('uploader', 'Unknown channel')}")
self.views_label.setText(f"Views: {formatted_views}")
self.like_count_label.setText(f"Likes: {formatted_likes}")
self.date_label.setText(f"Upload date: {formatted_date}")
self.duration_label.setText(f"Duration: {duration_str}")
def open_subtitle_dialog(self):
if not hasattr(self, 'available_subtitles') or not hasattr(self, 'available_automatic_subtitles'):
logger.warning("Subtitle info not loaded yet.")
return
def open_subtitle_dialog(self) -> None:
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
logger.warning("Subtitle info not loaded yet.")
return
if not hasattr(self, 'selected_subtitles'):
if not hasattr(self, "selected_subtitles"):
self.selected_subtitles = []
dialog = SubtitleSelectionDialog(
self.available_subtitles,
self.available_automatic_subtitles,
self.available_subtitles, # type: ignore[reportAttributeAccessIssue]
self.available_automatic_subtitles, # type: ignore[reportAttributeAccessIssue]
self.selected_subtitles,
self # Parent for the dialog
self, # Parent for the dialog
)
# Access the main application window (parent of the mixin's widget)
# to find the merge checkbox
main_window = self # In this context, self should be the YTSageApp instance
main_window = self # In this context, self should be the YTSageApp instance
if not isinstance(main_window, QMainWindow):
# If the structure is different, this might need adjustment
# Maybe self.parentWidget() or similar depending on how Mixin is used
logger.warning("Cannot find main window to access merge checkbox.")
merge_checkbox = None
# If the structure is different, this might need adjustment
# Maybe self.parentWidget() or similar depending on how Mixin is used
logger.warning("Cannot find main window to access merge checkbox.")
merge_checkbox = None
else:
merge_checkbox = getattr(main_window, 'merge_subs_checkbox', None)
merge_checkbox = getattr(main_window, "merge_subs_checkbox", None)
if dialog.exec(): # If user clicks OK
if dialog.exec(): # If user clicks OK
self.selected_subtitles = dialog.get_selected_subtitles()
logger.info(f"Selected subtitles: {self.selected_subtitles}")
# Update UI to reflect selection
@@ -292,7 +297,7 @@ class VideoInfoMixin:
# Enable/disable the merge checkbox in the parent window
if merge_checkbox:
# Only enable merge checkbox if we're not in Audio Only mode
is_audio_only = hasattr(main_window, 'audio_button') and main_window.audio_button.isChecked()
is_audio_only = hasattr(main_window, "audio_button") and main_window.audio_button.isChecked()
# In audio-only mode, we still allow subtitle selection but not merging
should_enable = count > 0 and not is_audio_only
merge_checkbox.setEnabled(should_enable)
@@ -304,29 +309,29 @@ class VideoInfoMixin:
self.subtitle_select_btn.style().polish(self.subtitle_select_btn)
# No else needed for cancel, state remains unchanged
def open_sponsorblock_dialog(self):
def open_sponsorblock_dialog(self) -> None:
"""Open the SponsorBlock category selection dialog."""
# Initialize selected categories if not exists or empty (first time opening)
if not hasattr(self, 'selected_sponsorblock_categories') or not self.selected_sponsorblock_categories:
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
# Use None to let the dialog set its own defaults
dialog_categories = None
else:
dialog_categories = self.selected_sponsorblock_categories
dialog = SponsorBlockCategoryDialog(dialog_categories, self)
if dialog.exec():
self.selected_sponsorblock_categories = dialog.get_selected_categories()
logger.info(f"SponsorBlock categories selected: {self.selected_sponsorblock_categories}")
self._update_sponsorblock_display()
def _update_sponsorblock_display(self):
def _update_sponsorblock_display(self) -> None:
"""Update the SponsorBlock button and label to reflect current selection."""
if not hasattr(self, 'selected_sponsorblock_categories'):
if not hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = []
count = len(self.selected_sponsorblock_categories)
# Update label text
if count == 0:
self.selected_sponsorblock_label.setText("0 selected")
@@ -334,15 +339,15 @@ class VideoInfoMixin:
self.selected_sponsorblock_label.setText("1 category selected")
else:
self.selected_sponsorblock_label.setText(f"{count} categories selected")
# Update button property for styling
self.sponsorblock_select_btn.setProperty("sponsorBlockSelected", count > 0)
# Force style refresh
self.sponsorblock_select_btn.style().unpolish(self.sponsorblock_select_btn)
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
def download_thumbnail(self, url):
def download_thumbnail(self, url) -> None:
try:
# Store both thumbnail URL and video URL
self.thumbnail_url = url
@@ -355,42 +360,39 @@ class VideoInfoMixin:
# Display thumbnail
image = self.thumbnail_image.resize((320, 180), Image.Resampling.LANCZOS)
img_byte_arr = BytesIO()
image.save(img_byte_arr, format='PNG')
image.save(img_byte_arr, format="PNG")
pixmap = QPixmap()
pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap)
except Exception as e:
logger.error(f"Error loading thumbnail: {str(e)}")
def download_thumbnail_file(self, video_url, path):
def download_thumbnail_file(self, video_url, path) -> bool:
if not self.save_thumbnail:
return False
try:
from yt_dlp import YoutubeDL
import requests # Use requests instead of urlopen
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = {
'quiet': True,
'skip_download': True,
'force_generic_extractor': False,
'no_warnings': True,
'extract_flat': False
"quiet": True,
"skip_download": True,
"force_generic_extractor": False,
"no_warnings": True,
"extract_flat": False,
}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
thumbnails = info.get('thumbnails', [])
thumbnails = info.get("thumbnails", [])
if not thumbnails:
raise ValueError("No thumbnails available")
thumbnail_url = max(
thumbnails,
key=lambda t: (t.get('height', 0) or 0) * (t.get('width', 0) or 0)
).get('url')
key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0),
).get("url")
if not thumbnail_url:
raise ValueError("Failed to extract thumbnail URL")
@@ -400,13 +402,13 @@ class VideoInfoMixin:
response.raise_for_status()
# Save the thumbnail
thumb_dir = os.path.join(path, 'Thumbnails')
os.makedirs(thumb_dir, exist_ok=True)
thumb_dir = Path(path).joinpath("Thumbnails")
thumb_dir.mkdir(exist_ok=True)
filename = f"{self.sanitize_filename(info['title'])}.jpg"
thumbnail_path = os.path.join(thumb_dir, filename)
thumbnail_path = thumb_dir.joinpath(filename)
with open(thumbnail_path, 'wb') as f:
with open(thumbnail_path, "wb") as f:
f.write(response.content)
logger.info(f"Thumbnail saved to: {thumbnail_path}")
@@ -419,6 +421,6 @@ class VideoInfoMixin:
self.signals.update_status.emit(error_msg)
return False
def sanitize_filename(self, name):
def sanitize_filename(self, name) -> str:
"""Clean filename for filesystem safety"""
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]
return re.sub(r'[\\/*?:"<>|]', "", name).strip()[:75]