v4.9.0 - Refactor (#41)

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

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

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

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

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

- imporve: return type hint for function.

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

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

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

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

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

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

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

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

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

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

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

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

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

download_path = ConfigManager.get("download_path")

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

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

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

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

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

* remove: import check, it should always work.

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

* chang back to checking import for ytdlp

* Bump version to 4.8.1

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

* Update asset paths in build workflows

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

* Bump version to 4.8.2

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

* Fix asset include path in Windows build workflow

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

* Update release tag examples in CI/CD README

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

* Fix include-files mapping in Windows build workflow

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

* Remove redundant comments in build-windows workflow

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

* Refactor Windows build to use cx_Freeze setup script

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

* Bump version to 4.8.3

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

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

---------

Co-authored-by: Your Name <mohamed.mohamed112@ai.mnu.edu.eg>
This commit is contained in:
Viren Hirpara
2025-09-10 01:31:19 +05:30
committed by GitHub
parent c140acef5a
commit e9de913b47
23 changed files with 825 additions and 714 deletions
+5 -8
View File
@@ -13,10 +13,7 @@ This package contains all dialog classes organized by functionality:
# 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 (
CustomOptionsDialog,
TimeRangeDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import 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,
@@ -30,21 +27,21 @@ __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",
@@ -3,6 +3,8 @@ Base dialogs for YTSage application.
Contains basic utility dialogs like LogWindow and AboutDialog.
"""
from datetime import datetime
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
@@ -155,7 +157,7 @@ class AboutDialog(QDialog):
layout.addWidget(title_label)
version_label = QLabel(
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.8.0b')}</span>"
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.8.3')}</span>"
)
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version_label)
@@ -392,8 +394,6 @@ class AboutDialog(QDialog):
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
@@ -426,8 +426,6 @@ class AboutDialog(QDialog):
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
@@ -8,7 +8,7 @@ import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, Signal, QObject
from PySide6.QtCore import Q_ARG, QMetaObject, QObject, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -31,31 +31,24 @@ from PySide6.QtWidgets import (
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import YTDLP_DOCS_URL
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 CommandWorker(QObject):
"""Worker class for running yt-dlp commands in a separate thread"""
# Signals for communicating with the main thread
output_received = Signal(str) # For command output lines
command_finished = Signal(bool, int) # For completion (success, exit_code)
error_occurred = Signal(str) # For errors
def __init__(self, command, url, path):
super().__init__()
self.command = command
self.url = url
self.path = path
def run_command(self):
"""Run the yt-dlp command and emit signals for output"""
try:
@@ -65,11 +58,11 @@ class CommandWorker(QObject):
# Build the full command
yt_dlp_path = get_yt_dlp_path()
base_cmd = [yt_dlp_path] + args
# Add download path if specified
if self.path:
base_cmd.extend(["-P", self.path])
# Add URL at the end
base_cmd.append(self.url)
@@ -94,14 +87,14 @@ class CommandWorker(QObject):
ret = proc.wait()
self.output_received.emit("=" * 50)
if ret != 0:
self.output_received.emit(f"❌ Command failed with exit code {ret}")
self.command_finished.emit(False, ret)
else:
self.output_received.emit("✅ Command completed successfully!")
self.command_finished.emit(True, ret)
except Exception as e:
self.output_received.emit("=" * 50)
self.error_occurred.emit(f"❌ Error executing command: {str(e)}")
@@ -160,7 +153,7 @@ class CustomOptionsDialog(QDialog):
# Convert Path to string properly and validate
cookie_path_str = str(self._parent.cookie_file_path)
# Only set if it looks like a valid path (more than just a drive letter)
if len(cookie_path_str) > 3 and not cookie_path_str.endswith(':'):
if len(cookie_path_str) > 3 and not cookie_path_str.endswith(":"):
self.cookie_path_input.setText(cookie_path_str)
path_layout.addWidget(self.cookie_path_input)
@@ -175,9 +168,7 @@ class CustomOptionsDialog(QDialog):
self.cookie_browser_group = QGroupBox("Browser Selection")
browser_layout = QVBoxLayout(self.cookie_browser_group)
browser_help = QLabel(
"Select the browser to extract cookies from. Make sure the browser is closed before extraction."
)
browser_help = QLabel("Select the browser to extract cookies from. Make sure the browser is closed before extraction.")
browser_help.setWordWrap(True)
browser_help.setStyleSheet("color: #999999; font-size: 11px;")
browser_layout.addWidget(browser_help)
@@ -186,16 +177,7 @@ class CustomOptionsDialog(QDialog):
browser_select_layout.addWidget(QLabel("Browser:"))
self.browser_combo = QComboBox()
self.browser_combo.addItems([
"chrome",
"firefox",
"safari",
"edge",
"opera",
"brave",
"chromium",
"vivaldi"
])
self.browser_combo.addItems(["chrome", "firefox", "safari", "edge", "opera", "brave", "chromium", "vivaldi"])
browser_select_layout.addWidget(self.browser_combo)
browser_layout.addLayout(browser_select_layout)
@@ -261,10 +243,7 @@ class CustomOptionsDialog(QDialog):
# Command input
self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText(
"Enter yt-dlp arguments here...\n\n"
"e.g. --extract-audio --audio-format mp3"
)
self.command_input.setPlaceholderText("Enter yt-dlp arguments here...\n\n" "e.g. --extract-audio --audio-format mp3")
self.command_input.setMinimumHeight(80) # Reduced further from 100
self.command_input.setStyleSheet(
"""
@@ -288,7 +267,7 @@ class CustomOptionsDialog(QDialog):
# Button layout
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
clear_btn = QPushButton("Clear")
clear_btn.clicked.connect(lambda: self.command_input.clear())
clear_btn.setStyleSheet(
@@ -308,15 +287,15 @@ class CustomOptionsDialog(QDialog):
"""
)
button_layout.addWidget(clear_btn)
button_layout.addStretch() # Push run button to the right
# Run command button
self.run_btn = QPushButton("Run Command")
self.run_btn.clicked.connect(self.run_custom_command)
self.run_btn.setDefault(True)
button_layout.addWidget(self.run_btn)
command_layout.addLayout(button_layout)
# Output label
@@ -469,18 +448,18 @@ class CustomOptionsDialog(QDialog):
if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option:
# Browser cookies are active
self.cookie_browser_radio.setChecked(True)
browser_parts = self._parent.browser_cookies_option.split(':')
browser_parts = self._parent.browser_cookies_option.split(":")
browser = browser_parts[0]
profile = browser_parts[1] if len(browser_parts) > 1 else ""
# Set browser selection
index = self.browser_combo.findText(browser)
if index >= 0:
self.browser_combo.setCurrentIndex(index)
# Set profile if any
self.profile_input.setText(profile)
self.cookie_status.setText(f"Browser cookies active: {self._parent.browser_cookies_option}")
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
elif hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
@@ -533,7 +512,7 @@ class CustomOptionsDialog(QDialog):
if self.cookie_browser_radio.isChecked():
browser = self.browser_combo.currentText()
profile = self.profile_input.text().strip()
if profile:
return f"{browser}:{profile}"
else:
@@ -571,12 +550,12 @@ class CustomOptionsDialog(QDialog):
# Create worker and thread
self.worker = CommandWorker(command, url, path)
self.worker_thread = threading.Thread(target=self.worker.run_command, daemon=True)
# Connect worker signals to our slots
self.worker.output_received.connect(self.on_output_received)
self.worker.command_finished.connect(self.on_command_finished)
self.worker.error_occurred.connect(self.on_error_occurred)
# Start the thread
self.worker_thread.start()
@@ -48,7 +48,7 @@ class FFmpegCheckDialog(QDialog):
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
self.setWindowIcon(QIcon(str(ICON_PATH)))
layout = QVBoxLayout(self)
layout.setSpacing(15)
@@ -3,11 +3,13 @@ Settings-related dialogs for YTSage application.
Contains dialogs for configuring download settings and auto-update preferences.
"""
import threading
import time
from datetime import datetime
import requests
from PySide6.QtCore import Qt
from packaging import version as version_parser
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
@@ -25,13 +27,13 @@ from PySide6.QtWidgets import (
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,
)
from src.utils.ytsage_logger import logger
class DownloadSettingsDialog(QDialog):
@@ -164,7 +166,7 @@ class DownloadSettingsDialog(QDialog):
path_group_box = QGroupBox("Download Path")
path_layout = QVBoxLayout()
self.path_display = QLabel(self.current_path)
self.path_display = QLabel(str(self.current_path))
self.path_display.setWordWrap(True)
self.path_display.setStyleSheet(
"QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }"
@@ -246,7 +248,7 @@ class DownloadSettingsDialog(QDialog):
layout.addWidget(button_box)
def browse_new_path(self) -> None:
new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", str(self.current_path))
if new_path:
self.current_path = new_path
self.path_display.setText(self.current_path)
@@ -329,8 +331,6 @@ class DownloadSettingsDialog(QDialog):
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.Icon.Information,
@@ -349,7 +349,7 @@ class DownloadSettingsDialog(QDialog):
msg_box = self._create_styled_message_box(
QMessageBox.Icon.Warning,
"Update Check",
f"Error checking for updates: {str(e)}",
f"Error checking for updates: {e}",
)
msg_box.exec()
@@ -381,7 +381,7 @@ class DownloadSettingsDialog(QDialog):
else:
QMessageBox.warning(self, "Error", "Failed to save auto-update settings.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {str(e)}")
QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {e}")
# Call the parent accept method to close the dialog
super().accept()
@@ -582,7 +582,7 @@ class AutoUpdateSettingsDialog(QDialog):
self.on_enable_toggled(settings["enabled"])
except Exception as e:
logger.error(f"Error loading auto-update settings: {e}")
logger.exception(f"Error loading auto-update settings: {e}")
def update_next_check_label(self) -> None:
"""Update the next check label based on current settings."""
@@ -616,7 +616,7 @@ class AutoUpdateSettingsDialog(QDialog):
except Exception as e:
self.next_check_label.setText("Next check: Error calculating")
logger.error(f"Error calculating next check time: {e}")
logger.exception(f"Error calculating next check time: {e}")
def on_enable_toggled(self, enabled) -> None:
"""Handle enable/disable checkbox toggle."""
@@ -646,16 +646,12 @@ class AutoUpdateSettingsDialog(QDialog):
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}")
logger.exception(f"Error during manual check: {e}")
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
# Run in separate thread to avoid blocking UI
import threading
threading.Thread(target=check_in_thread, daemon=True).start()
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
@@ -738,6 +734,6 @@ class AutoUpdateSettingsDialog(QDialog):
)
msg_box.exec()
except Exception as e:
logger.error(f"Error saving auto-update settings: {e}")
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
logger.exception(f"Error saving auto-update settings: {e}")
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {e}")
msg_box.exec()
@@ -7,6 +7,7 @@ import os
import subprocess
import sys
import time
from importlib.metadata import PackageNotFoundError
from pathlib import Path
import requests
@@ -14,24 +15,26 @@ 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
from src.utils.ytsage_logger import logger
try:
from importlib.metadata import version as importlib_version
from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
from importlib.metadata import version as importlib_version
def get_version(package_name: str) -> str:
return importlib_version(package_name)
PackageNotFoundError = ImportlibPackageNotFoundError
except ImportError:
# Fallback for older Python versions
import pkg_resources
def get_version(package_name: str) -> str:
return pkg_resources.get_distribution(package_name).version
PackageNotFoundError = pkg_resources.DistributionNotFound
try:
@@ -71,7 +74,7 @@ class VersionCheckThread(QThread):
else:
error_message = "yt-dlp not available."
self.finished.emit(current_version, latest_version, error_message)
return
return
except subprocess.TimeoutExpired:
# Try fallback if timeout
if YT_DLP_AVAILABLE:
@@ -162,11 +165,11 @@ class UpdateThread(QThread):
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)}"
error_message = f"❌ Network error during update: {e}"
self.update_status.emit(error_message)
success = False
except Exception as e:
error_message = f"❌ Update failed: {str(e)}"
error_message = f"❌ Update failed: {e}"
self.update_status.emit(error_message)
success = False
@@ -207,7 +210,7 @@ class UpdateThread(QThread):
return False
except Exception as e:
logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True)
logger.exception(f"UpdateThread: Unexpected error during update: {e}")
self.update_status.emit(f"❌ Unexpected error during update: {e}")
return False
@@ -553,10 +556,7 @@ class AutoUpdateThread(QThread):
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.exception(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
self.update_finished.emit(False, f"Update check error: {e}")
except Exception as e:
@@ -595,7 +595,7 @@ class AutoUpdateThread(QThread):
return self._update_via_pip()
except Exception as e:
logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
logger.exception(f"AutoUpdateThread: Error in _perform_update: {e}")
return False
def _update_binary(self, yt_dlp_path: Path) -> bool:
@@ -629,7 +629,7 @@ class AutoUpdateThread(QThread):
return False
except Exception as e:
logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True)
logger.exception(f"AutoUpdateThread: Unexpected error during update: {e}")
return False
def _update_via_pip(self) -> bool:
@@ -684,5 +684,5 @@ class AutoUpdateThread(QThread):
return True
except Exception as e:
logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
logger.exception(f"AutoUpdateThread: Pip update failed: {e}")
return False
+21 -8
View File
@@ -1,7 +1,12 @@
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp
class FormatSignals(QObject):
format_update = Signal(list)
@@ -9,8 +14,9 @@ class FormatSignals(QObject):
class FormatTableMixin:
def setup_format_table(self) -> QTableWidget:
self.format_signals = FormatSignals()
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.format_signals = FormatSignals()
# Format table with improved styling
self.format_table = QTableWidget()
self.format_table.setColumnCount(8)
@@ -124,6 +130,8 @@ class FormatTableMixin:
return self.format_table
def filter_formats(self) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
if not hasattr(self, "all_formats"):
return
@@ -165,6 +173,8 @@ class FormatTableMixin:
self.format_signals.format_update.emit(filtered_formats)
def _update_format_table(self, formats) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.format_table.setRowCount(0)
self.format_checkboxes.clear()
@@ -331,22 +341,30 @@ class FormatTableMixin:
self.format_table.setItem(row, 7, notes_item)
def handle_checkbox_click(self, clicked_checkbox) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
for checkbox in self.format_checkboxes:
if checkbox != clicked_checkbox:
checkbox.setChecked(False)
def get_selected_format(self):
self = cast("YTSageApp", self) # for autocompletion and type inference.
for checkbox in self.format_checkboxes:
if checkbox.isChecked():
return checkbox.format_id
return None
def update_format_table(self, formats) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.all_formats = formats
self.format_signals.format_update.emit(formats)
def get_quality_label(self, format_info) -> str:
"""Determine quality label based on format information"""
self = cast("YTSageApp", self) # for autocompletion and type inference.
if format_info.get("vcodec") == "none":
# Audio quality
abr = format_info.get("abr", 0)
@@ -383,17 +401,12 @@ class FormatTableMixin:
def _get_format_notes(self, format_info) -> str:
"""Generate helpful format notes based on format info."""
self = cast("YTSageApp", self) # for autocompletion and type inference.
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", "")
height = 0
if resolution:
try:
height = int(resolution.split("x")[1])
except:
pass
# Better file size categories
if file_size > 50 * 1024 * 1024: # Over 50MB
+155 -152
View File
@@ -5,9 +5,10 @@ import webbrowser
from pathlib import Path
import markdown
import pyglet
import requests
from packaging import version
from PySide6.QtCore import Q_ARG, QMetaObject, Qt
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -28,9 +29,7 @@ from PySide6.QtWidgets import (
)
from src.core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import check_ffmpeg # Import utility functions
from src.core.ytsage_utils import load_saved_path, save_path, should_check_for_auto_update, parse_yt_dlp_error
from src.core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update
from src.core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
AboutDialog,
@@ -45,35 +44,24 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__
from src.gui.ytsage_gui_format_table import FormatTableMixin
from src.gui.ytsage_gui_video_info import VideoInfoMixin
from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS
from src.utils.ytsage_logger import logger
try:
import yt_dlp
from yt_dlp.utils import ExtractorError, DownloadError
from yt_dlp.utils import DownloadError, ExtractorError
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
try:
import pyglet
PYGLET_AVAILABLE = True
except ImportError:
PYGLET_AVAILABLE = False
class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins
def __init__(self) -> None:
super().__init__()
# Initialize logger for this class
self.logger = logger.bind(module="YTSageApp")
# Log startup warnings for missing dependencies
if not YT_DLP_AVAILABLE:
self.logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
if not PYGLET_AVAILABLE:
self.logger.warning("pyglet not available, audio notifications disabled")
logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
# Check for FFmpeg before proceeding
if not check_ffmpeg():
@@ -84,9 +72,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if ytdlp_path == "yt-dlp": # Not found in app dir or PATH
self.show_ytdlp_setup_dialog()
else:
self.logger.info(f"Using yt-dlp from: {ytdlp_path}")
logger.info(f"Using yt-dlp from: {ytdlp_path}")
self.version = "4.8.0b"
self.version = "4.8.3"
self.check_for_updates()
# Check for auto-updates if enabled
@@ -95,9 +83,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
load_saved_path(self)
# Load custom icon
if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
self.setWindowIcon(QIcon(str(ICON_PATH)))
else:
self.logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
self.signals = SignalManager()
self.download_paused = False
@@ -120,8 +108,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.video_url = ""
self.selected_subtitles = [] # Initialize selected subtitles list
# Initialize cookie settings - ensure they start clean
self.cookie_file_path = None
self.browser_cookies_option = None
self.cookie_file_path = None
self.browser_cookies_option = None
self.speed_limit_value = None # Store speed limit value
self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
self.download_section = None
@@ -305,53 +293,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Initialize UI state based on current mode
self.handle_mode_change()
# Initialize pyglet for sound notifications
self.init_sound()
def init_sound(self) -> None:
"""Initialize pyglet for sound notifications"""
try:
if PYGLET_AVAILABLE:
self.sound_enabled = True
# sound_path logic moved to src\utils\ytsage_constants.py
self.notification_sound_path = SOUND_PATH
# Check if the notification sound file exists
if not self.notification_sound_path.exists():
self.logger.warning(f"Notification sound file not found at: {self.notification_sound_path}")
self.sound_enabled = False
else:
self.logger.info(f"Notification sound loaded from: {self.notification_sound_path}")
else:
self.sound_enabled = False
self.logger.info("Sound notifications disabled - pyglet not available")
except Exception as e:
self.logger.error(f"Error initializing sound: {e}")
self.sound_enabled = False
# Init_sound method is removed, serve no purpose.
def play_notification_sound(self) -> None:
"""Play notification sound in a separate thread to avoid blocking the UI"""
if not self.sound_enabled:
return
"""Play notification sound asynchronously (non-blocking)."""
try:
# Check if the notification sound file exists
if not SOUND_PATH.exists():
logger.warning(f"Notification sound file not found at: {SOUND_PATH}")
return
def play_sound() -> None:
try:
if PYGLET_AVAILABLE:
# Play the sound using pyglet
sound = pyglet.media.load(str(self.notification_sound_path))
sound.play()
except Exception as e:
self.logger.error(f"Error playing notification sound: {e}")
# Play sound in a separate thread to avoid blocking the UI
sound_thread = threading.Thread(target=play_sound)
sound_thread.daemon = True
sound_thread.start()
# Removed load_saved_path and save_path methods since their functionality is now handled directly by ytsage_utils
# Play the sound using pyglet
# no need for the thread, as .play() is async
sound = pyglet.media.load(str(SOUND_PATH), streaming=False)
sound.play()
logger.debug("Notification sound played")
except Exception as e:
logger.exception(f"Error playing notification sound: {e}")
def init_ui(self) -> None:
self.setWindowTitle(f"YTSage v{self.version}")
@@ -731,6 +689,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Initial extraction with basic options - suppress warnings here too
ydl_opts = {
"logger": logger,
"quiet": False,
"no_warnings": True, # <-- Suppress warnings for initial check
"extract_flat": True,
@@ -745,30 +704,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path:
ydl_opts["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
ydl_opts["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0],
self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None)
ydl_opts["cookiesfrombrowser"] = (
self.browser_cookies_option.split(":")[0],
self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
basic_info = ydl.extract_info(url, download=False)
if not basic_info:
# This case usually means the URL is invalid or not found
raise Exception("Invalid URL or video not found. Please check the link and try again.")
except (ExtractorError, DownloadError) as e:
# This is a yt-dlp specific error, use the original message
user_friendly_error = parse_yt_dlp_error(str(e))
raise Exception(user_friendly_error)
logger.error("Could not extract basic video information")
self.signals.update_status.emit(
"Error: Could not extract basic video information. Please check your link."
)
# Hide playlist UI on error
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
except Exception as e:
# This is our own exception or other unexpected error
self.logger.error(f"First extraction failed: {str(e)}")
self.logger.error(f"Exception type: {type(e)}")
logger.exception(f"First extraction failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e))
raise Exception(user_friendly_error)
self.signals.update_status.emit(user_friendly_error)
return
self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info")
# Configure options for detailed extraction (keep other options)
# Add no_warnings here as well, as this is where detailed info is fetched
ydl_opts_detail = {
"logger": logger,
"extract_flat": False,
"format": None,
"writesubtitles": True,
@@ -785,8 +750,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path:
ydl_opts_detail["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
ydl_opts_detail["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0],
self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None)
ydl_opts_detail["cookiesfrombrowser"] = (
self.browser_cookies_option.split(":")[0],
self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
)
# Use a separate options dict for the detailed extraction
with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail:
@@ -800,25 +767,30 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Ensure there are entries before proceeding
if not self.playlist_entries:
raise Exception("Playlist contains no valid videos.")
logger.error("Playlist contains no valid videos.")
self.signals.update_status.emit("Error: Playlist contains no valid videos.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Extract detailed info for the FIRST video in the playlist
# This provides formats/subs for the UI, assuming consistency
first_video_url = self.playlist_entries[0].get("url")
if not first_video_url:
raise Exception("Could not get URL for the first playlist video.")
logger.error("Could not get URL for the first playlist video.")
self.signals.update_status.emit("Error: Could not get URL for the first playlist video.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
try:
# Use the ydl_detail instance with no_warnings
self.video_info = ydl_detail.extract_info(first_video_url, download=False)
except (ExtractorError, DownloadError) as first_video_error:
# Use error parser for yt-dlp specific playlist video errors
user_friendly_error = parse_yt_dlp_error(str(first_video_error))
raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}")
except Exception as first_video_error:
# Use error parser for other playlist video errors too
user_friendly_error = parse_yt_dlp_error(str(first_video_error))
raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}")
except Exception as e:
logger.exception(f"Failed to extract info for the first playlist video: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e))
self.signals.update_status.emit(user_friendly_error)
return
# Update playlist info label text (remains the same)
playlist_text = (
f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos"
@@ -847,8 +819,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
self.logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
raise Exception("No format information available")
logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
self.signals.update_status.emit("Error: No format information available.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (60%)... Processing formats")
self.all_formats = self.video_info["formats"]
@@ -861,7 +836,6 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Try to get thumbnail from playlist info first
# Fallback to video thumbnail if playlist thumbnail not found or not a playlist
thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail")
self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled - use the stored VIDEO URL
@@ -901,22 +875,19 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.video_button.setChecked(True)
self.audio_button.setChecked(False)
self.filter_formats()
self.signals.update_status.emit("Analysis complete!")
except (ExtractorError, DownloadError) as e:
logger.error(f"yt-dlp detailed extraction failed: {e}", exc_info=True)
# Use the error parser for yt-dlp specific errors
user_friendly_error = parse_yt_dlp_error(str(e))
raise Exception(user_friendly_error)
except Exception as e:
logger.error(f"Detailed extraction failed: {e}", exc_info=True)
logger.exception(f"Detailed extraction failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
# Use the error parser for other extraction errors too
user_friendly_error = parse_yt_dlp_error(str(e))
raise Exception(user_friendly_error)
self.signals.update_status.emit(user_friendly_error)
return
except Exception as e:
self.logger.error(f"Error in analysis: {e}", exc_info=True)
logger.exception(f"Error in analysis: {e}")
self.signals.update_status.emit(f"Error: {e}")
# Ensure playlist UI is hidden on error too
# update signal method from QMetaObject.invokeMethod to signals
@@ -944,7 +915,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.last_path = new_path
save_path(self, self.last_path) # Save the updated path
path_changed = True
self.logger.info(f"Download path updated to: {self.last_path}")
logger.info(f"Download path updated to: {self.last_path}")
# Update Speed Limit
new_limit_value = dialog.get_selected_speed_limit()
@@ -954,7 +925,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.speed_limit_value = new_limit_value
self.speed_limit_unit_index = new_unit_index
limit_changed = True
self.logger.info(
logger.info(
f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}"
)
@@ -1030,7 +1001,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
self.download_thumbnail_file(url, path)
except Exception as e:
self.logger.warning(f"Thumbnail download failed: {e}")
logger.warning(f"Thumbnail download failed: {e}", exc_info=True)
# Optionally inform the user, but don't stop the main download
# Create download thread with resolution in output template
@@ -1116,7 +1087,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
int_value = int(value)
self.progress_bar.setValue(int_value)
except Exception as e:
self.logger.error(f"Progress bar update error: {str(e)}")
logger.exception(f"Progress bar update error: {e}")
def toggle_pause(self) -> None:
if self.current_download:
@@ -1145,7 +1116,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
changelog = latest_release.get("body", "No changelog available.") # Get changelog body
self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog
except Exception as e:
self.logger.error(f"Failed to check for updates: {str(e)}", exc_info=True)
logger.exception(f"Failed to check for updates: {e}")
def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter
msg = QDialog(self)
@@ -1161,7 +1132,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Fallback to icon file
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
msg.setWindowIcon(QIcon(ICON_PATH.as_posix()))
msg.setWindowIcon(QIcon(str(ICON_PATH)))
except Exception:
pass
@@ -1224,7 +1195,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
)
changelog_text.setHtml(html_changelog)
except Exception as e:
self.logger.warning(f"Error converting changelog markdown to HTML: {e}")
logger.warning(f"Error converting changelog markdown to HTML: {e}", exc_info=True)
changelog_text.setPlainText(changelog) # Fallback to plain text
changelog_text.setStyleSheet(
@@ -1339,14 +1310,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
# Check if auto-update should be performed
if should_check_for_auto_update():
self.logger.info("Performing auto-update check for yt-dlp...")
logger.info("Performing auto-update check for yt-dlp...")
# Perform the auto-update in a non-blocking way
# We don't want to block the UI startup for this
from PySide6.QtCore import QTimer
QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup
except Exception as e:
self.logger.error(f"Error in auto-update check: {e}", exc_info=True)
logger.exception(f"Error in auto-update check: {e}")
def _perform_auto_update(self) -> None:
"""Actually perform the auto-update check and update if needed in a background thread."""
@@ -1357,14 +1326,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.auto_update_thread.update_finished.connect(self._on_auto_update_finished)
self.auto_update_thread.start()
except Exception as e:
self.logger.error(f"Error starting auto-update thread: {e}", exc_info=True)
logger.exception(f"Error starting auto-update thread: {e}")
def _on_auto_update_finished(self, success, message) -> None:
"""Handle auto-update completion."""
if success:
self.logger.info(f"Auto-update completed successfully: {message}")
logger.info(f"Auto-update completed successfully: {message}")
else:
self.logger.warning(f"Auto-update completed with issues: {message}")
logger.warning(f"Auto-update completed with issues: {message}")
# Clean up the thread reference and ensure it's properly finished
if hasattr(self, "auto_update_thread"):
@@ -1382,26 +1351,26 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
# Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
self.logger.info("Stopping auto-update thread...")
logger.info("Stopping auto-update thread...")
self.auto_update_thread.quit()
if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown
self.logger.warning("Force terminating auto-update thread...")
logger.warning("Force terminating auto-update thread...")
self.auto_update_thread.terminate()
self.auto_update_thread.wait(1000) # Wait for termination
# Cancel any running downloads
if self.current_download and self.current_download.isRunning():
self.logger.info("Canceling running download...")
logger.info("Canceling running download...")
self.current_download.cancel()
if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown
self.logger.warning("Force terminating download thread...")
logger.warning("Force terminating download thread...")
self.current_download.terminate()
self.current_download.wait(1000) # Wait for termination
self.logger.info("Application closing...")
logger.info("Application closing...")
event.accept()
except Exception as e:
self.logger.error(f"Error during application close: {e}", exc_info=True)
logger.exception(f"Error during application close: {e}")
event.accept() # Accept the close event anyway
def show_custom_options(self) -> None:
@@ -1410,14 +1379,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
# Clear both first to avoid conflicts
self.cookie_file_path = None
self.browser_cookies_option = None
if cookie_path:
self.cookie_file_path = cookie_path
self.logger.info(f"Selected cookie file: {self.cookie_file_path}")
logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
@@ -1425,7 +1394,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
)
elif browser_cookies:
self.browser_cookies_option = browser_cookies
self.logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
@@ -1499,32 +1468,32 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# --- Add Toggle Methods Here ---
def toggle_save_thumbnail(self, state) -> None:
self.logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state)
self.logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
def toggle_save_description(self, state) -> None:
self.logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state)
self.logger.debug(f"Save description toggled: {self.save_description}")
logger.debug(f"Save description toggled: {self.save_description}")
def toggle_embed_chapters(self, state) -> None:
self.logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state)
self.logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
# --- End Toggle Methods ---
def open_playlist_selection_dialog(self) -> None:
if not self.is_playlist or not self.playlist_entries:
self.logger.info("No playlist data available to select from.")
logger.info("No playlist data available to select from.")
return
dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
if dialog.exec():
self.selected_playlist_items = dialog.get_selected_items_string()
self.logger.info(f"Playlist items selected: {self.selected_playlist_items}")
logger.info(f"Playlist items selected: {self.selected_playlist_items}")
# Update button text (this call is safe as it happens in the main thread after dialog closes)
if self.selected_playlist_items is None:
@@ -1612,11 +1581,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
if cookie_path:
self.cookie_file_path = cookie_path
self.browser_cookies_option = None # Clear browser cookies if file is used
self.logger.info(f"Selected cookie file: {self.cookie_file_path}")
logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
@@ -1625,7 +1594,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
elif browser_cookies:
self.browser_cookies_option = browser_cookies
self.cookie_file_path = None # Clear file cookies if browser is used
self.logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
@@ -1717,7 +1686,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
yt_dlp_path = get_yt_dlp_path()
if not yt_dlp_path:
raise Exception("yt-dlp executable not found. Please install yt-dlp first.")
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
self.signals.update_status.emit("Error: yt-dlp executable not found. Please install yt-dlp first.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable")
@@ -1740,16 +1713,29 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SUBPROCESS_CREATIONFLAGS)
if result.returncode != 0:
raise Exception(f"yt-dlp failed: {result.stderr}")
logger.error(f"yt-dlp failed: {result.stderr}")
self.signals.update_status.emit(f"Error: yt-dlp failed: {result.stderr}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Parse JSON output - yt-dlp outputs one JSON object per line for playlists
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
if not json_lines:
raise Exception("No data returned from yt-dlp")
logger.error("No data returned from yt-dlp")
self.signals.update_status.emit("Error: No data returned from yt-dlp")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Parse first JSON object to determine if it's a playlist
first_info = json.loads(json_lines[0])
try:
first_info = json.loads(json_lines[0])
except json.JSONDecodeError as e:
logger.error(f"Failed to parse yt-dlp output: {e}")
self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (60%)... Processing data")
@@ -1770,7 +1756,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
continue
if not self.playlist_entries:
raise Exception("Playlist contains no valid videos.")
logger.error("Playlist contains no valid videos.")
self.signals.update_status.emit("Error: Playlist contains no valid videos.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
# Use first video for format information
self.video_info = self.playlist_entries[0]
@@ -1806,7 +1796,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
raise Exception("No format information available")
logger.error("No format information available")
self.signals.update_status.emit("Error: No format information available.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
self.signals.update_status.emit("Analyzing (75%)... Processing formats")
self.all_formats = self.video_info["formats"]
@@ -1845,8 +1839,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.signals.update_status.emit("Analysis complete!")
except subprocess.TimeoutExpired:
raise Exception("Analysis timed out. Please try again.")
logger.error("Analysis timed out. Please try again.")
self.signals.update_status.emit("Error: Analysis timed out. Please try again.")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
except json.JSONDecodeError as e:
raise Exception(f"Failed to parse yt-dlp output: {str(e)}")
logger.error(f"Failed to parse yt-dlp output: {e}")
self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
except Exception as e:
raise Exception(f"Analysis failed: {str(e)}")
logger.error(f"Analysis failed: {e}")
self.signals.update_status.emit(f"Error: Analysis failed: {e}")
self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
+33 -19
View File
@@ -2,22 +2,28 @@ import re
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, cast
import requests
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
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,
)
from src.utils.ytsage_logger import logger
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp
class VideoInfoMixin:
def setup_video_info_section(self) -> QHBoxLayout:
self = cast("YTSageApp", self) # for autocompletion and type inference.
# Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout()
media_info_layout.setSpacing(15)
@@ -184,6 +190,8 @@ class VideoInfoMixin:
return media_info_layout
def setup_playlist_info_section(self) -> QLabel:
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.playlist_info_label = QLabel()
self.playlist_info_label.setVisible(False)
self.playlist_info_label.setStyleSheet(
@@ -205,6 +213,8 @@ class VideoInfoMixin:
return self.playlist_info_label
def update_video_info(self, info) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
if hasattr(self, "is_playlist") and self.is_playlist:
# Playlist Mode: Show playlist title and video count
self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist"))
@@ -260,6 +270,8 @@ class VideoInfoMixin:
self.duration_label.setText(f"Duration: {duration_str}")
def open_subtitle_dialog(self) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
logger.warning("Subtitle info not loaded yet.")
return
@@ -274,16 +286,8 @@ class VideoInfoMixin:
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
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
else:
merge_checkbox = getattr(main_window, "merge_subs_checkbox", None)
# removed extra logic for mapping to main_windows
merge_checkbox = getattr(self, "merge_subs_checkbox", None)
if dialog.exec(): # If user clicks OK
self.selected_subtitles = dialog.get_selected_subtitles()
@@ -296,7 +300,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(self, "audio_button") and self.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)
@@ -310,6 +314,8 @@ class VideoInfoMixin:
def open_sponsorblock_dialog(self) -> None:
"""Open the SponsorBlock category selection dialog."""
self = cast("YTSageApp", self) # for autocompletion and type inference.
# Initialize selected categories if not exists or empty (first time opening)
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
# Use None to let the dialog set its own defaults
@@ -326,6 +332,8 @@ class VideoInfoMixin:
def _update_sponsorblock_display(self) -> None:
"""Update the SponsorBlock button and label to reflect current selection."""
self = cast("YTSageApp", self) # for autocompletion and type inference.
if not hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = []
@@ -347,6 +355,8 @@ class VideoInfoMixin:
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
def download_thumbnail(self, url) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
try:
# Store both thumbnail URL and video URL
self.thumbnail_url = url
@@ -364,7 +374,10 @@ class VideoInfoMixin:
pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap)
except Exception as e:
logger.error(f"Error loading thumbnail: {str(e)}")
logger.exception(f"Error loading thumbnail: {e}")
def download_thumbnail_file(self, video_url, path) -> bool:
self = cast("YTSageApp", self) # for autocompletion and type inference.
def download_thumbnail_file(self, video_url, path) -> bool:
if not self.save_thumbnail:
@@ -373,10 +386,11 @@ class VideoInfoMixin:
try:
# Import yt_dlp locally to avoid import errors when yt-dlp is not installed
from yt_dlp import YoutubeDL
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = {
"logger": logger,
"quiet": True,
"skip_download": True,
"force_generic_extractor": False,
@@ -389,7 +403,7 @@ class VideoInfoMixin:
thumbnails = info.get("thumbnails", [])
if not thumbnails:
raise ValueError("No thumbnails available")
logger.info("No thumbnails available")
thumbnail_url = max(
thumbnails,
@@ -397,7 +411,7 @@ class VideoInfoMixin:
).get("url")
if not thumbnail_url:
raise ValueError("Failed to extract thumbnail URL")
logger.info("Failed to extract thumbnail URL")
# Download using requests
response = requests.get(thumbnail_url)
@@ -418,8 +432,8 @@ class VideoInfoMixin:
return True
except Exception as e:
error_msg = f"❌ Thumbnail error: {str(e)}"
logger.error(f"Thumbnail Save Error: {str(e)}")
error_msg = f"❌ Thumbnail error: {e}"
logger.exception(f"Thumbnail Save Error: {e}")
self.signals.update_status.emit(error_msg)
return False