From e9de913b4728f411140a5a5672442484f2b19229 Mon Sep 17 00:00:00 2001 From: Viren Hirpara <62017836+viru185@users.noreply.github.com> Date: Wed, 10 Sep 2025 01:31:19 +0530 Subject: [PATCH] v4.9.0 - Refactor (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/CI_CD_README.md | 8 +- .github/workflows/build-linux.yml | 6 +- .github/workflows/build-macos.yml | 10 +- .github/workflows/build-windows.yml | 110 +++++-- main.py | 7 +- src/__init__.py | 2 +- src/core/ytsage_downloader.py | 63 ++-- src/core/ytsage_ffmpeg.py | 36 +- src/core/ytsage_logging.py | 238 -------------- src/core/ytsage_utils.py | 216 ++++++------ src/core/ytsage_yt_dlp.py | 16 +- src/gui/ytsage_gui_dialogs/__init__.py | 13 +- .../ytsage_gui_dialogs/ytsage_dialogs_base.py | 8 +- .../ytsage_dialogs_custom.py | 67 ++-- .../ytsage_dialogs_ffmpeg.py | 2 +- .../ytsage_dialogs_settings.py | 30 +- .../ytsage_dialogs_update.py | 30 +- src/gui/ytsage_gui_format_table.py | 29 +- src/gui/ytsage_gui_main.py | 307 +++++++++--------- src/gui/ytsage_gui_video_info.py | 52 +-- src/utils/ytsage_config_manager.py | 202 ++++++++++++ src/utils/ytsage_constants.py | 31 +- src/utils/ytsage_logger.py | 56 ++++ 23 files changed, 825 insertions(+), 714 deletions(-) delete mode 100644 src/core/ytsage_logging.py create mode 100644 src/utils/ytsage_config_manager.py create mode 100644 src/utils/ytsage_logger.py diff --git a/.github/CI_CD_README.md b/.github/CI_CD_README.md index 0373f80..cfcea69 100644 --- a/.github/CI_CD_README.md +++ b/.github/CI_CD_README.md @@ -126,14 +126,14 @@ To add new platform packages: ```bash # For a new release -git tag v4.8.0 -git push origin v4.8.0 +git tag v4.8.1 +git push origin v4.8.1 # For a patch release git tag v4.8.1 git push origin v4.8.1 # To delete a tag (if needed) -git tag -d v4.8.0 -git push origin :refs/tags/v4.8.0 +git tag -d v4.8.1 +git push origin :refs/tags/v4.8.1 ``` diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index fdc4d8a..f7416a4 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -122,7 +122,7 @@ jobs: ], include_files=[ ("src", "src"), - ("assets", "assets"), + ("assets", "lib/assets"), ("ytsage.desktop", "share/applications/ytsage.desktop"), ("assets/branding/icons/icon.png", "share/pixmaps/ytsage.png"), ], @@ -185,8 +185,8 @@ jobs: # Remove screenshots to reduce size before packaging build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true) - if [ -n "$build_dir" ] && [ -d "$build_dir/assets/branding/screenshots" ]; then - rm -rf "$build_dir/assets/branding/screenshots" + if [ -n "$build_dir" ] && [ -d "$build_dir/lib/assets/branding/screenshots" ]; then + rm -rf "$build_dir/lib/assets/branding/screenshots" echo "Removed screenshots folder from $build_dir" fi diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index abb9881..52a95c4 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -123,7 +123,7 @@ jobs: ], include_files=[ ("src", "src"), - ("assets", "assets"), + ("assets", "lib/assets"), ], ) @@ -171,8 +171,8 @@ jobs: if [ -z "$app_path" ]; then app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true) fi - if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then - rm -rf "$app_path/Contents/Resources/assets/branding/screenshots" + if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then + rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots" echo "Removed screenshots folder from .app bundle at $app_path" fi echo "Post-bdist_mac directory listing:" @@ -207,8 +207,8 @@ jobs: app_base="$(basename "$app_path")" app_parent="$(dirname "$app_path")" # Ensure screenshots folder is not shipped - if [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then - rm -rf "$app_path/Contents/Resources/assets/branding/screenshots" + if [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then + rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots" echo "Removed screenshots folder from .app bundle at $app_path" fi (cd "$app_parent" && zip -r "${GITHUB_WORKSPACE}/artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip" "$app_base") diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 64df03a..057648a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -62,6 +62,79 @@ jobs: echo "VERSION=$version" >> $env:GITHUB_ENV Write-Host "Prepared build variables for version: $version" + - name: Create cx_Freeze setup script + shell: powershell + run: | + # Create setup script for cx_Freeze + New-Item -Path "setup_cxfreeze.py" -ItemType File -Force + Add-Content -Path "setup_cxfreeze.py" -Value "import os" + Add-Content -Path "setup_cxfreeze.py" -Value "from cx_Freeze import setup, Executable" + Add-Content -Path "setup_cxfreeze.py" -Value "" + Add-Content -Path "setup_cxfreeze.py" -Value 'version = os.environ.get("VERSION", "0.0.0")' + Add-Content -Path "setup_cxfreeze.py" -Value "" + Add-Content -Path "setup_cxfreeze.py" -Value "build_exe_options = dict(" + Add-Content -Path "setup_cxfreeze.py" -Value " optimize=2," + Add-Content -Path "setup_cxfreeze.py" -Value " packages=[" + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtCore",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtGui",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWidgets",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "requests",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "packaging",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "markdown",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "pyglet",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "loguru",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "setuptools",' + Add-Content -Path "setup_cxfreeze.py" -Value " ]," + Add-Content -Path "setup_cxfreeze.py" -Value " excludes=[" + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtBluetooth",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtNetwork",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtOpenGL",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtPrintSupport",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSvg",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtTest",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtXml",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSql",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtHelp",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtMultimedia",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQml",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQuick",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWebEngineCore",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageDraw",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageFont",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "numpy",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "scipy",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "wx",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "pandas",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "tkinter",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "yt_dlp",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "unittest",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "test",' + Add-Content -Path "setup_cxfreeze.py" -Value ' "tests",' + Add-Content -Path "setup_cxfreeze.py" -Value " ]," + Add-Content -Path "setup_cxfreeze.py" -Value " include_files=[" + Add-Content -Path "setup_cxfreeze.py" -Value ' ("src", "src"),' + Add-Content -Path "setup_cxfreeze.py" -Value ' ("assets", "lib/assets"),' + Add-Content -Path "setup_cxfreeze.py" -Value " ]," + Add-Content -Path "setup_cxfreeze.py" -Value ")" + Add-Content -Path "setup_cxfreeze.py" -Value "" + Add-Content -Path "setup_cxfreeze.py" -Value "executables = [" + Add-Content -Path "setup_cxfreeze.py" -Value " Executable(" + Add-Content -Path "setup_cxfreeze.py" -Value ' script="main.py",' + Add-Content -Path "setup_cxfreeze.py" -Value ' target_name=f"YTSage-v{version}.exe",' + Add-Content -Path "setup_cxfreeze.py" -Value ' base="Win32GUI",' + Add-Content -Path "setup_cxfreeze.py" -Value ' icon="assets/branding/icons/YTSage.ico",' + Add-Content -Path "setup_cxfreeze.py" -Value " )" + Add-Content -Path "setup_cxfreeze.py" -Value "]" + Add-Content -Path "setup_cxfreeze.py" -Value "" + Add-Content -Path "setup_cxfreeze.py" -Value "setup(" + Add-Content -Path "setup_cxfreeze.py" -Value ' name="YTSage",' + Add-Content -Path "setup_cxfreeze.py" -Value " version=version," + Add-Content -Path "setup_cxfreeze.py" -Value ' description="YTSage",' + Add-Content -Path "setup_cxfreeze.py" -Value ' options={"build_exe": build_exe_options},' + Add-Content -Path "setup_cxfreeze.py" -Value " executables=executables," + Add-Content -Path "setup_cxfreeze.py" -Value ")" + - name: Build Standard Version (ZIP) shell: powershell run: | @@ -74,21 +147,12 @@ jobs: if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force } if (Test-Path "dist") { Remove-Item "dist" -Recurse -Force } - # Build executable using cx_Freeze CLI (use python -m to ensure proper base handling) - python -m cx_Freeze main.py ` - --target-dir "dist\YTSage" ` - --base-name Win32GUI ` - --icon "assets\branding\icons\YTSage.ico" ` - --target-name "YTSage-v$version.exe" ` - --optimize 2 ` - --packages "PySide6.QtCore,PySide6.QtGui,PySide6.QtWidgets,requests,PIL,packaging,markdown,pyglet,loguru,setuptools" ` - --excludes "PySide6.QtBluetooth,PySide6.QtNetwork,PySide6.QtOpenGL,PySide6.QtPrintSupport,PySide6.QtSvg,PySide6.QtTest,PySide6.QtXml,PySide6.QtSql,PySide6.QtHelp,PySide6.QtMultimedia,PySide6.QtQml,PySide6.QtQuick,PySide6.QtWebEngineCore,PIL.ImageDraw,PIL.ImageFont,numpy,scipy,wx,pandas,tkinter,yt_dlp,unittest,test,tests" ` - --include-files "src" ` - --include-files "assets" + # Build executable using setup script + python setup_cxfreeze.py build_exe --build-exe "dist\YTSage" # Remove screenshots folder to reduce build size - if (Test-Path "dist\YTSage\assets\branding\screenshots") { - Remove-Item "dist\YTSage\assets\branding\screenshots" -Recurse -Force + if (Test-Path "dist\YTSage\lib\assets\branding\screenshots") { + Remove-Item "dist\YTSage\lib\assets\branding\screenshots" -Recurse -Force Write-Host "Removed screenshots folder from standard build" } @@ -153,21 +217,15 @@ jobs: if (Test-Path "build\exe.*") { Remove-Item "build\exe.*" -Recurse -Force } if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force } - # Build executable with FFmpeg using cx_Freeze CLI (use python -m to ensure proper base handling) - python -m cx_Freeze main.py ` - --target-dir "dist\YTSage-FFmpeg" ` - --base-name Win32GUI ` - --icon "assets\branding\icons\YTSage.ico" ` - --target-name "YTSage-v$version-ffmpeg.exe" ` - --optimize 2 ` - --packages "PySide6.QtCore,PySide6.QtGui,PySide6.QtWidgets,requests,PIL,packaging,markdown,pyglet,loguru,setuptools" ` - --excludes "PySide6.QtBluetooth,PySide6.QtNetwork,PySide6.QtOpenGL,PySide6.QtPrintSupport,PySide6.QtSvg,PySide6.QtTest,PySide6.QtXml,PySide6.QtSql,PySide6.QtHelp,PySide6.QtMultimedia,PySide6.QtQml,PySide6.QtQuick,PySide6.QtWebEngineCore,PIL.ImageDraw,PIL.ImageFont,numpy,scipy,wx,pandas,tkinter,yt_dlp,unittest,test,tests" ` - --include-files "src" ` - --include-files "assets" + # Create modified setup script for FFmpeg version + (Get-Content "setup_cxfreeze.py") -replace 'YTSage-v\{version\}\.exe', 'YTSage-v{version}-ffmpeg.exe' | Set-Content "setup_cxfreeze_ffmpeg.py" + + # Build executable with FFmpeg using setup script + python setup_cxfreeze_ffmpeg.py build_exe --build-exe "dist\YTSage-FFmpeg" # Remove screenshots folder to reduce build size - if (Test-Path "dist\YTSage-FFmpeg\assets\branding\screenshots") { - Remove-Item "dist\YTSage-FFmpeg\assets\branding\screenshots" -Recurse -Force + if (Test-Path "dist\YTSage-FFmpeg\lib\assets\branding\screenshots") { + Remove-Item "dist\YTSage-FFmpeg\lib\assets\branding\screenshots" -Recurse -Force Write-Host "Removed screenshots folder from FFmpeg build" } diff --git a/main.py b/main.py index cfbb542..e8e8eab 100644 --- a/main.py +++ b/main.py @@ -2,11 +2,8 @@ import sys from PySide6.QtWidgets import QApplication, QMessageBox -from src.core.ytsage_logging import logger -from src.core.ytsage_yt_dlp import ( # Import the new yt-dlp setup functions - check_ytdlp_binary, - setup_ytdlp, -) +from src.utils.ytsage_logger import logger +from src.core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp # Import the new yt-dlp setup functions from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main diff --git a/src/__init__.py b/src/__init__.py index d9e962d..dc97c83 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader A modern, user-friendly YouTube video downloader built with PySide6. """ -__version__ = "4.8.0b" +__version__ = "4.8.3" __author__ = "oop7" diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py index 42369a6..e123ac5 100644 --- a/src/core/ytsage_downloader.py +++ b/src/core/ytsage_downloader.py @@ -7,9 +7,9 @@ from pathlib import Path from PySide6.QtCore import QObject, QThread, Signal -from src.core.ytsage_logging import logger from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS +from src.utils.ytsage_logger import logger try: import yt_dlp # Keep yt_dlp import here - only downloader uses it. @@ -38,6 +38,7 @@ class DownloadThread(QThread): error_signal = Signal(str) file_exists_signal = Signal(str) # New signal for file existence update_details = Signal(str) # New signal for filename, speed, ETA + update_details = Signal(str) # New signal for filename, speed, ETA def __init__( self, @@ -97,9 +98,10 @@ class DownloadThread(QThread): try: file_path.unlink(missing_ok=True) except Exception as e: - logger.error(f"Error deleting {file_path.name}: {str(e)}") + logger.exception(f"Error deleting {file_path.name}: {e}") except Exception as e: - self.error_signal.emit(f"Error cleaning partial files: {str(e)}") + logger.exception(f"Error cleaning partial files: {e}") + self.error_signal.emit(f"Error cleaning partial files: {e}") def cleanup_subtitle_files(self) -> None: """Delete subtitle files after they have been merged into the video file""" @@ -111,7 +113,7 @@ class DownloadThread(QThread): logger.debug(f"Deleted subtitle file: {path.name}") return True except Exception as e: - logger.error(f"Error deleting subtitle file {path}: {e}") + logger.exception(f"Error deleting subtitle file {path}: {e}") return False try: @@ -130,7 +132,7 @@ class DownloadThread(QThread): else: logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files") except Exception as e: - logger.error(f"Error cleaning subtitle files: {str(e)}") + logger.exception(f"Error cleaning subtitle files: {e}") def check_file_exists(self) -> bool | None: """Check if the file already exists before downloading""" @@ -138,18 +140,21 @@ class DownloadThread(QThread): logger.debug("Starting file existence check") # Use yt-dlp to get the filename without downloading, suppressing warnings ydl_opts_check = { + "logger": logger, # passed app logger "quiet": True, "skip_download": True, "no_warnings": True, # <-- Suppress warnings during check "ignoreerrors": True, # Also ignore other potential errors during this check - "outtmpl": {"default": f"{self.path.as_posix()}/%(title)s.%(ext)s"}, + "outtmpl": {"default": str(self.path / "%(title)s.%(ext)s")}, "format": (self.format_id if self.format_id else "best"), # Use selected format or best } if self.cookie_file: ydl_opts_check["cookiefile"] = str(self.cookie_file) elif self.browser_cookies: - ydl_opts_check["cookiesfrombrowser"] = (self.browser_cookies.split(':')[0], - self.browser_cookies.split(':')[1] if ':' in self.browser_cookies else None) + ydl_opts_check["cookiesfrombrowser"] = ( + self.browser_cookies.split(":")[0], + self.browser_cookies.split(":")[1] if ":" in self.browser_cookies else None, + ) if YT_DLP_AVAILABLE: with yt_dlp.YoutubeDL(ydl_opts_check) as ydl: @@ -178,10 +183,7 @@ class DownloadThread(QThread): return False # Proceed with download attempt except Exception as e: - logger.debug(f"Error checking file existence: {str(e)}") - import traceback - - traceback.print_exc() + logger.exception(f"Error checking file existence: {e}") return None def _build_yt_dlp_command(self) -> list: @@ -201,6 +203,7 @@ class DownloadThread(QThread): try: if YT_DLP_AVAILABLE: ydl_opts = { + "logger": logger, "quiet": True, "no_warnings": True, "skip_download": True, @@ -214,7 +217,7 @@ class DownloadThread(QThread): logger.debug(f"Detected audio-only format for ID: {clean_format_id}") break except Exception as e: - logger.debug(f"Error checking if format is audio-only: {e}") + logger.exception(f"Error checking if format is audio-only: {e}") # For audio-only formats, don't try to merge with video if is_audio_format: @@ -229,12 +232,9 @@ class DownloadThread(QThread): try: format_ext = None logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})") + if YT_DLP_AVAILABLE: - ydl_opts = { - "quiet": True, - "no_warnings": True, - "skip_download": True, - } + ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True, "logger": logger} with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(self.url, download=False) or {} # Look for the clean format ID first @@ -254,7 +254,7 @@ class DownloadThread(QThread): # Ensure output matches the selected format - only for video formats cmd.extend(["--merge-output-format", format_ext]) except Exception as e: - logger.debug(f"Error detecting format extension: {e}") + logger.exception(f"Error detecting format extension: {e}") # If we can't determine the format, don't specify merge-output-format pass else: @@ -272,7 +272,7 @@ class DownloadThread(QThread): else: output_template = f"{base_path}/%(title)s_%(resolution)s.%(ext)s" - cmd.extend(["-o", output_template]) + cmd.extend(["-o", str(output_template)]) # Add common options cmd.append("--force-overwrites") @@ -295,7 +295,7 @@ class DownloadThread(QThread): lang_code = sub_selection.split(" - ")[0] lang_codes.append(lang_code) except Exception as e: - logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}") + logger.exception(f"Could not parse subtitle selection '{sub_selection}': {e}") if lang_codes: cmd.extend(["--sub-langs", ",".join(lang_codes)]) @@ -366,7 +366,7 @@ class DownloadThread(QThread): self.initial_subtitle_files.add(file) logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download") except Exception as e: - logger.warning(f"Error scanning for initial subtitle files: {e}") + logger.exception(f"Error scanning for initial subtitle files: {e}") if self.use_direct_command: # Use direct CLI command instead of Python API @@ -377,10 +377,8 @@ class DownloadThread(QThread): except Exception as e: # Catch errors during setup - self.error_signal.emit(f"Critical error in download thread: {str(e)}") - import traceback - - traceback.print_exc() + logger.critical(f"Critical error in download thread: {e}", exc_info=True) + self.error_signal.emit(f"Critical error in download thread: {e}") def _run_direct_command(self) -> None: """Run yt-dlp as a direct command line process instead of using Python API.""" @@ -460,7 +458,8 @@ class DownloadThread(QThread): self.cleanup_partial_files() except Exception as e: - self.error_signal.emit(f"Error in direct command: {str(e)}") + logger.exception(f"Error in direct command: {e}") + self.error_signal.emit(f"Error in direct command: {e}") self.cleanup_partial_files() def _parse_output_line(self, line) -> None: @@ -513,7 +512,7 @@ class DownloadThread(QThread): else: self.status_signal.emit(f"⏬ Downloading...") except Exception as e: - logger.error(f"Error extracting filename from line '{line}': {e}") + logger.exception(f"Error extracting filename from line '{line}': {e}") self.status_signal.emit("⚡ Downloading...") # Fallback status return # Don't process this line further for speed/ETA @@ -535,16 +534,16 @@ class DownloadThread(QThread): ) if subtitle_match: subtitle_file = subtitle_match.group(1).strip() - + # Clean up the path - remove any duplicated directory paths # Sometimes yt-dlp output contains malformed paths like "dir: dir/file" - if ":" in subtitle_file and os.name == 'nt': # Windows paths + if ":" in subtitle_file and os.name == "nt": # Windows paths # Look for pattern like "C:\path: C:\path\file" and extract the latter colon_parts = subtitle_file.split(": ") if len(colon_parts) > 1: # Take the last part which should be the actual file path subtitle_file = colon_parts[-1].strip() - + # Show subtitle download message self.status_signal.emit(f"⏬ Downloading subtitle...") # Store the subtitle file path for later deletion if merging is enabled @@ -607,7 +606,7 @@ class DownloadThread(QThread): self.update_details.emit(status) except Exception as e: # If parsing fails, just show basic status (maybe log the error) - logger.error(f"Error parsing download details line: {line} -> {e}") + logger.exception(f"Error parsing download details line: {line} -> {e}") pass # Keep basic status emission below if needed, or emit generic details # Check for post-processing diff --git a/src/core/ytsage_ffmpeg.py b/src/core/ytsage_ffmpeg.py index c932d10..8befa7d 100644 --- a/src/core/ytsage_ffmpeg.py +++ b/src/core/ytsage_ffmpeg.py @@ -7,7 +7,7 @@ from pathlib import Path import requests -from src.core.ytsage_logging import logger +from src.utils.ytsage_logger import logger from src.utils.ytsage_constants import ( FFMPEG_7Z_DOWNLOAD_URL, FFMPEG_7Z_SHA256_URL, @@ -46,7 +46,7 @@ def download_file(url, dest_path, progress_callback=None) -> bool: progress_callback(f"⚡ Downloading FFmpeg components... {progress}%") return True except requests.RequestException as e: - logger.info(f"Download error: {str(e)}") + logger.info(f"Download error: {e}") return False @@ -80,7 +80,7 @@ def verify_sha256(file_path, expected_hash_url) -> bool: logger.info(f"Actual: {actual_hash}") return False except Exception as e: - logger.info(f"⚠️ SHA-256 verification error: {str(e)}") + logger.info(f"⚠️ SHA-256 verification error: {e}") return False @@ -128,7 +128,7 @@ def get_ffmpeg_path() -> str | Path: ffmpeg_path = result.stdout.strip() return ffmpeg_path except Exception as e: - logger.error(f"Error finding ffmpeg in PATH: {e}") + logger.exception(f"Error finding ffmpeg in PATH: {e}") # If not found in PATH, check the installation directory ffmpeg_install_path = get_ffmpeg_install_path() @@ -171,7 +171,7 @@ def check_ffmpeg_installed() -> bool: return True return False except Exception as e: - logger.info(f"FFmpeg check error: {str(e)}") + logger.info(f"FFmpeg check error: {e}") return False @@ -219,7 +219,7 @@ def install_ffmpeg_windows() -> bool: timeout=300, ) # 5-minute timeout except Exception as e: - logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...") + logger.exception(f"7z extraction failed: {e}, trying zip fallback...") use_7zip = False else: logger.error("SHA-256 verification failed for 7z file, trying zip fallback...") @@ -236,7 +236,8 @@ def install_ffmpeg_windows() -> bool: temp_file, progress_callback=lambda msg: logger.debug(msg), ): - raise Exception("Failed to download FFmpeg (both 7z and zip methods failed)") + logger.exception("Failed to download FFmpeg (both 7z and zip methods failed)") + return False logger.info("Extracting FFmpeg components from zip archive...") try: @@ -245,7 +246,8 @@ def install_ffmpeg_windows() -> bool: with zipfile.ZipFile(temp_file, "r") as zip_ref: zip_ref.extractall(extract_dir) except Exception as e: - raise Exception(f"Extraction failed: {str(e)}") + logger.exception(f"Extraction failed: {e}") + return False logger.info("Configuring system paths...") # Add to System Path @@ -265,13 +267,14 @@ def install_ffmpeg_windows() -> bool: # Verify installation if not check_ffmpeg_installed(): - raise Exception("FFmpeg installation verification failed") + logger.error("FFmpeg installation verification failed") + return False logger.info("FFmpeg installation completed successfully!") return True except Exception as e: - logger.error(f"Error installing FFmpeg: {str(e)}") + logger.exception(f"Error installing FFmpeg: {e}") return False @@ -298,12 +301,13 @@ def install_ffmpeg_macos() -> bool: # Verify installation if not check_ffmpeg_installed(): - raise Exception("FFmpeg installation verification failed") + logger.error("FFmpeg installation verification failed") + return False return True except Exception as e: - logger.error(f"Error installing FFmpeg: {str(e)}") + logger.exception(f"Error installing FFmpeg: {e}") return False @@ -329,16 +333,18 @@ def install_ffmpeg_linux() -> bool: # Universal snap package subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300) else: - raise Exception("No supported package manager found") + logger.error("No supported package manager found") + return False # Verify installation if not check_ffmpeg_installed(): - raise Exception("FFmpeg installation verification failed") + logger.error("FFmpeg installation verification failed") + return False return True except Exception as e: - logger.error(f"Error installing FFmpeg: {str(e)}") + logger.exception(f"Error installing FFmpeg: {e}") return False diff --git a/src/core/ytsage_logging.py b/src/core/ytsage_logging.py deleted file mode 100644 index 7a17589..0000000 --- a/src/core/ytsage_logging.py +++ /dev/null @@ -1,238 +0,0 @@ -""" -YTSage logging configuration using loguru. - -This module provides centralized logging configuration for the entire YTSage application. -It replaces the inefficient print statements with structured logging using loguru. -""" - -import sys -from pathlib import Path - -from src.utils.ytsage_constants import APP_LOG_DIR - -# Try to import loguru, but handle case where it might not be available -try: - from loguru import logger - - LOGURU_AVAILABLE = True -except ImportError: - LOGURU_AVAILABLE = False - - # Create a dummy logger class that does nothing - class DummyLogger: - def info(self, *args, **kwargs): - pass - - def debug(self, *args, **kwargs): - pass - - def warning(self, *args, **kwargs): - pass - - def error(self, *args, **kwargs): - pass - - def critical(self, *args, **kwargs): - pass - - def remove(self, *args, **kwargs): - pass - - def add(self, *args, **kwargs): - pass - - def bind(self, *args, **kwargs): - return self - - @property - def _core(self): - class Core: - handlers = [] - - return Core() - - logger = DummyLogger() - - -def setup_logging(): - """ - Configure loguru logging for YTSage application. - - Sets up multiple log levels and outputs: - - Console output for INFO and above - - File output for DEBUG and above - - Separate error log file for ERROR and above - """ - - if not LOGURU_AVAILABLE: - return logger - - # Remove default logger to avoid duplicate output - try: - logger.remove() - except Exception: - pass - - # Get the application data directory with fallbacks - try: - # logic moved to src\utils\ytsage_constants.py - log_dir = APP_LOG_DIR - except Exception: - # Ultimate fallback - use current directory - log_dir = Path.cwd() / "logs" - - # Create log directory if it doesn't exist - try: - log_dir.mkdir(parents=True, exist_ok=True) - except Exception: - # If we can't create the log directory, fall back to current directory - log_dir = Path.cwd() - try: - log_dir.mkdir(exist_ok=True) - except Exception: - pass # If we still can't create it, we'll just log to console - - # Console handler - INFO and above, with colors - # Check if stdout is available (it might be None in PyInstaller windowed apps) - stdout_available = sys.stdout is not None - - if stdout_available: - try: - logger.add( - sys.stdout, - level="INFO", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - colorize=True, - catch=True, - ) - except Exception: - # Fallback to basic console logging without colors - try: - logger.add( - sys.stdout, - level="INFO", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", - catch=True, - ) - except Exception: - stdout_available = False - - # If stdout is not available, try stderr or skip console logging entirely - if not stdout_available: - try: - if sys.stderr is not None: - logger.add( - sys.stderr, - level="WARNING", # Only warnings and errors to stderr - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", - catch=True, - ) - except Exception: - # If even stderr fails, we'll rely only on file logging - pass - - # Only add file handlers if we successfully created a log directory - if log_dir and log_dir.exists(): - try: - # Main log file - DEBUG and above, with rotation - logger.add( - log_dir / "ytsage.log", - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - rotation="10 MB", # Rotate when file reaches 10MB - retention="7 days", # Keep logs for 7 days - compression="zip", # Compress old logs - catch=True, - ) - - # Error log file - ERROR and above only - logger.add( - log_dir / "ytsage_errors.log", - level="ERROR", - format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", - rotation="5 MB", - retention="30 days", # Keep error logs longer - compression="zip", - catch=True, - ) - except Exception as e: - # If file logging fails, just log to console - logger.warning(f"Could not set up file logging: {e}") - - # Log startup message if we have any handlers - if logger._core.handlers: - logger.info("YTSage logging system initialized") - if log_dir and log_dir.exists(): - logger.debug(f"Log directory: {log_dir}") - else: - logger.warning("File logging disabled - could not create log directory") - - # If no handlers were successfully added, add a null handler to prevent errors - if not logger._core.handlers: - # Add a minimal handler that just discards messages - # This prevents loguru from complaining about no handlers - import tempfile - - try: - # Try to add a temporary file handler as last resort - temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log" - logger.add(temp_log, level="ERROR", catch=True) - except Exception: - # If even that fails, we're in a very restricted environment - # loguru should handle this gracefully with its internal fallbacks - pass - - return logger - - -def get_logger(name: str | None = None): - """ - Get a logger instance for a specific module. - - Args: - name: Name of the module/component requesting the logger - - Returns: - Configured logger instance - """ - if name: - return logger.bind(name=name) - return logger - - -# Initialize logging when module is imported - with maximum safety -_setup_complete = False - - -def safe_setup(): - """Safely initialize logging with multiple fallback strategies.""" - global _setup_complete - if _setup_complete: - return logger - - try: - setup_logging() - _setup_complete = True - except Exception: - # If all else fails, create an even simpler logger that just prints - if LOGURU_AVAILABLE: - try: - logger.remove() - except Exception: - pass - - # At this point, just ensure we have something that won't crash - _setup_complete = True - - return logger - - -# Try to set up logging, but don't let it crash the module import -try: - safe_setup() -except Exception: - # Ultimate fallback - the module will still import successfully - pass - -# Export the main logger for convenience -__all__ = ["logger", "get_logger", "setup_logging"] diff --git a/src/core/ytsage_utils.py b/src/core/ytsage_utils.py index 4ac1d5e..64bf19f 100644 --- a/src/core/ytsage_utils.py +++ b/src/core/ytsage_utils.py @@ -4,28 +4,13 @@ import subprocess import sys import tempfile import time +from importlib.metadata import PackageNotFoundError from pathlib import Path -try: - from importlib.metadata import version as importlib_version - from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError - - 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 - import requests from packaging import version from src.core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path -from src.core.ytsage_logging import logger from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.utils.ytsage_constants import ( APP_CONFIG_FILE, @@ -35,6 +20,25 @@ from src.utils.ytsage_constants import ( YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL, ) +from src.utils.ytsage_logger import logger + +try: + 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 + # Cache for version information to avoid delays _version_cache = { @@ -108,7 +112,7 @@ def load_version_cache_from_config() -> None: if tool_name in _version_cache: _version_cache[tool_name].update(cache_data) except Exception as e: - logger.error(f"Error loading version cache: {e}") + logger.exception(f"Error loading version cache: {e}") def save_version_cache_to_config() -> None: @@ -118,7 +122,7 @@ def save_version_cache_to_config() -> None: config["cached_versions"] = _version_cache.copy() save_config(config) except Exception as e: - logger.error(f"Error saving version cache: {e}") + logger.exception(f"Error saving version cache: {e}") def get_ytdlp_version_cached() -> str: @@ -140,7 +144,7 @@ def get_ytdlp_version_cached() -> str: return version_info except Exception as e: - logger.error(f"Error getting cached yt-dlp version: {e}") + logger.exception(f"Error getting cached yt-dlp version: {e}") return "Error getting version" @@ -164,7 +168,7 @@ def get_ffmpeg_version_cached() -> str: return version_info except Exception as e: - logger.error(f"Error getting cached FFmpeg version: {e}") + logger.exception(f"Error getting cached FFmpeg version: {e}") return "Error getting version" @@ -182,7 +186,7 @@ def refresh_version_cache(force=False) -> bool: return True except Exception as e: - logger.error(f"Error refreshing version cache: {e}") + logger.exception(f"Error refreshing version cache: {e}") return False @@ -215,7 +219,7 @@ def get_ytdlp_version_direct(yt_dlp_path=None) -> str: else: return "Error getting version" except Exception as e: - logger.error(f"Error getting yt-dlp version: {e}") + logger.exception(f"Error getting yt-dlp version: {e}") return "Error getting version" @@ -269,10 +273,10 @@ def get_ffmpeg_version_direct() -> str: return "Unknown version" return "Not found" except Exception as e: - logger.error(f"Error getting FFmpeg version from install path: {e}") + logger.exception(f"Error getting FFmpeg version from install path: {e}") return "Not found" except Exception as e: - logger.error(f"Error getting FFmpeg version: {e}") + logger.exception(f"Error getting FFmpeg version: {e}") return "Error getting version" @@ -308,7 +312,7 @@ def load_config() -> dict: config[key] = value return config except (json.JSONDecodeError, UnicodeError, Exception) as e: - logger.error(f"Error reading config file: {e}") + logger.exception(f"Error reading config file: {e}") # If config file is corrupted, create a new one with defaults save_config(default_config) @@ -322,7 +326,7 @@ def save_config(config) -> bool: json.dump(config, f, ensure_ascii=False, indent=2) return True except Exception as e: - logger.error(f"Error saving config: {e}") + logger.exception(f"Error saving config: {e}") return False @@ -342,7 +346,7 @@ def check_ffmpeg() -> bool: os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" return True except Exception as e: - logger.error(f"Error updating PATH: {e}") + logger.exception(f"Error updating PATH: {e}") return False # For macOS, check common paths @@ -359,13 +363,13 @@ def check_ffmpeg() -> bool: os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}" return True except Exception as e: - logger.error(f"Error updating PATH: {e}") + logger.exception(f"Error updating PATH: {e}") continue return False except Exception as e: - logger.error(f"Error checking FFmpeg: {e}") + logger.exception(f"Error checking FFmpeg: {e}") return False @@ -381,7 +385,7 @@ def load_saved_path(main_window_instance) -> None: main_window_instance.last_path = saved_path return except (json.JSONDecodeError, UnicodeError) as e: - logger.error(f"Error reading config file: {e}") + logger.exception(f"Error reading config file: {e}") # If config file is corrupted, try to remove it try: APP_CONFIG_FILE.unlink(missing_ok=True) @@ -397,7 +401,7 @@ def load_saved_path(main_window_instance) -> None: main_window_instance.last_path = tempfile.gettempdir() except Exception as e: - logger.error(f"Error loading saved settings: {e}") + logger.exception(f"Error loading saved settings: {e}") main_window_instance.last_path = tempfile.gettempdir() @@ -409,7 +413,7 @@ def save_path(main_window_instance, path) -> bool: try: Path(path).mkdir(exist_ok=True) except Exception as e: - logger.error(f"Error creating directory: {e}") + logger.exception(f"Error creating directory: {e}") return False if not os.access(path, os.W_OK): @@ -423,7 +427,7 @@ def save_path(main_window_instance, path) -> bool: return True except Exception as e: - logger.error(f"Error saving settings: {e}") + logger.exception(f"Error saving settings: {e}") return False @@ -484,13 +488,13 @@ def update_yt_dlp() -> bool: logger.info("yt-dlp binary successfully updated") return True except Exception as e: - logger.error(f"Error replacing yt-dlp binary: {e}") + logger.exception(f"Error replacing yt-dlp binary: {e}") return False else: logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}") return False except Exception as e: - logger.error(f"Error downloading yt-dlp update: {e}") + logger.exception(f"Error downloading yt-dlp update: {e}") return False else: # We're using a system-installed yt-dlp, use pip to update @@ -540,9 +544,9 @@ def update_yt_dlp() -> bool: else: logger.info(f"Failed to get latest version info: HTTP {response.status_code}") except Exception as e: - logger.error(f"Error checking for yt-dlp updates: {e}") + logger.exception(f"Error checking for yt-dlp updates: {e}") except Exception as e: - logger.info(f"Unexpected error during yt-dlp update: {e}") + logger.exception(f"Unexpected error during yt-dlp update: {e}") return False @@ -573,7 +577,7 @@ def should_check_for_auto_update() -> bool: return False except Exception as e: - logger.error(f"Error checking auto-update schedule: {e}") + logger.exception(f"Error checking auto-update schedule: {e}") return False @@ -602,9 +606,7 @@ def check_and_update_ytdlp_auto() -> bool: logger.info(f"Latest yt-dlp version: {latest_version}") # Compare versions - from packaging import version as version_parser - - if version_parser.parse(latest_version) > version_parser.parse(current_version): + if version.parse(latest_version) > version.parse(current_version): logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...") # Perform the update @@ -630,11 +632,11 @@ def check_and_update_ytdlp_auto() -> bool: logger.info(f"Network error during auto-update check: {e}") return False except Exception as e: - logger.error(f"Error during auto-update check: {e}") + logger.exception(f"Error during auto-update check: {e}") return False except Exception as e: - logger.info(f"Critical error in auto-update: {e}") + logger.critical(f"Critical error in auto-update: {e}", exc_info=True) return False @@ -657,77 +659,103 @@ def update_auto_update_settings(enabled, frequency) -> bool: save_config(config) return True except Exception as e: - logger.error(f"Error updating auto-update settings: {e}") + logger.exception(f"Error updating auto-update settings: {e}") return False def parse_yt_dlp_error(error_message: str) -> str: """ Parse yt-dlp error messages and return user-friendly error messages. - + Args: error_message: The raw error message from yt-dlp - + Returns: str: A user-friendly error message with actionable advice """ - error_str = str(error_message).lower() - + error_str = error_message.lower() + # Private video errors - if any(keyword in error_str for keyword in ['private video', 'login_required', 'sign in if you']): - return ("This is a private video. You can download it by logging into your account using cookies.\n" - "Go to 'Custom Options' → 'Login with Cookies' → 'Extract cookies from browser' to authenticate.") - + if any(keyword in error_str for keyword in ["private video", "login_required", "sign in if you"]): + return ( + "This is a private video. You can download it by logging into your account using cookies.\n" + "Go to 'Custom Options' → 'Login with Cookies' → 'Extract cookies from browser' to authenticate." + ) + # Age-restricted content - if any(keyword in error_str for keyword in ['age restricted', 'age-restricted', 'confirm your age']): - return ("This video is age-restricted. You need to be logged in to access it.\n" - "Use 'Custom Options' → 'Login with Cookies' to authenticate with your account.") - + if any(keyword in error_str for keyword in ["age restricted", "age-restricted", "confirm your age"]): + return ( + "This video is age-restricted. You need to be logged in to access it.\n" + "Use 'Custom Options' → 'Login with Cookies' to authenticate with your account." + ) + # Geo-blocked content - if any(keyword in error_str for keyword in ['not available in your country', 'geo-blocked', 'video is not available', 'not made this video available in your country']): - return ("This video is not available in your region (geo-blocked).\n" - "You may need to use a VPN or the video might be restricted in your country.") - + if any( + keyword in error_str + for keyword in [ + "not available in your country", + "geo-blocked", + "video is not available", + "not made this video available in your country", + ] + ): + return ( + "This video is not available in your region (geo-blocked).\n" + "You may need to use a VPN or the video might be restricted in your country." + ) + # Removed/deleted videos - if any(keyword in error_str for keyword in ['video unavailable', 'this video has been removed', 'video does not exist']): - return ("This video has been removed or is no longer available.\n" - "The video may have been deleted by the uploader or removed due to policy violations.") - + if any(keyword in error_str for keyword in ["video unavailable", "this video has been removed", "video does not exist"]): + return ( + "This video has been removed or is no longer available.\n" + "The video may have been deleted by the uploader or removed due to policy violations." + ) + # Live stream errors - if any(keyword in error_str for keyword in ['live stream', 'livestream', 'is live']): - return ("This is a live stream that cannot be downloaded while active.\n" - "Wait for the stream to end, then try downloading the archived version.") - + if any(keyword in error_str for keyword in ["live stream", "livestream", "is live"]): + return ( + "This is a live stream that cannot be downloaded while active.\n" + "Wait for the stream to end, then try downloading the archived version." + ) + # Playlist errors - if any(keyword in error_str for keyword in ['playlist', 'no entries']): - return ("Unable to access this playlist. It may be private, deleted, or empty.\n" - "Check if the playlist exists and is publicly accessible.") - + if any(keyword in error_str for keyword in ["playlist", "no entries"]): + return ( + "Unable to access this playlist. It may be private, deleted, or empty.\n" + "Check if the playlist exists and is publicly accessible." + ) + # Network/connection errors - if any(keyword in error_str for keyword in ['network error', 'connection', 'timeout', 'unable to download']): - return ("Network connection error. Please check your internet connection and try again.\n" - "If the problem persists, the video server might be temporarily unavailable.") - + if any(keyword in error_str for keyword in ["network error", "connection", "timeout", "unable to download"]): + return ( + "Network connection error. Please check your internet connection and try again.\n" + "If the problem persists, the video server might be temporarily unavailable." + ) + # Invalid URL - if any(keyword in error_str for keyword in ['invalid url', 'unsupported url', 'no video found']): - return ("Invalid or unsupported URL. Please check the link and try again.\n" - "Make sure you're using a valid YouTube, Vimeo, or other supported platform URL.") - + if any(keyword in error_str for keyword in ["invalid url", "unsupported url", "no video found"]): + return ( + "Invalid or unsupported URL. Please check the link and try again.\n" + "Make sure you're using a valid YouTube, Vimeo, or other supported platform URL." + ) + # YouTube premium content - if any(keyword in error_str for keyword in ['youtube premium', 'premium', 'members only']): - return ("This content requires YouTube Premium or channel membership.\n" - "You need to be logged in with an account that has access to this content.") - + if any(keyword in error_str for keyword in ["youtube premium", "premium", "members only"]): + return ( + "This content requires YouTube Premium or channel membership.\n" + "You need to be logged in with an account that has access to this content." + ) + # Copyright/DMCA - if any(keyword in error_str for keyword in ['copyright', 'dmca', 'blocked']): - return ("This video is blocked due to copyright claims.\n" - "The content owner has restricted access to this video.") - + if any(keyword in error_str for keyword in ["copyright", "dmca", "blocked"]): + return "This video is blocked due to copyright claims.\n" "The content owner has restricted access to this video." + # Extraction errors (could be temporary) - if any(keyword in error_str for keyword in ['unable to extract', 'extraction failed']): - return ("Failed to extract video information. This might be a temporary issue.\n" - "Please try again in a few minutes, or check if the video link is correct.") - + if any(keyword in error_str for keyword in ["unable to extract", "extraction failed"]): + return ( + "Failed to extract video information. This might be a temporary issue.\n" + "Please try again in a few minutes, or check if the video link is correct." + ) + # Generic fallback with the original error for debugging - return (f"Could not extract video information. Please check your link.\n" - f"Technical details: {error_message}") + return f"Could not extract video information. Please check your link.\n" f"Technical details: {error_message}" diff --git a/src/core/ytsage_yt_dlp.py b/src/core/ytsage_yt_dlp.py index b0b2a30..ed76240 100644 --- a/src/core/ytsage_yt_dlp.py +++ b/src/core/ytsage_yt_dlp.py @@ -20,7 +20,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from src.core.ytsage_logging import logger +from src.utils.ytsage_logger import logger from src.utils.ytsage_constants import ( APP_BIN_DIR, ICON_PATH, @@ -94,7 +94,7 @@ class YtdlpSetupDialog(QDialog): # icon_path logic moved to src\utils\ytsage_constants.py icon_path = ICON_PATH if Path.exists(icon_path): - self.setWindowIcon(QIcon(icon_path.as_posix())) + self.setWindowIcon(QIcon(str(icon_path))) self.init_ui() @@ -390,11 +390,11 @@ class YtdlpSetupDialog(QDialog): self.setup_complete.emit(target_path) self.accept() except Exception as copy_error: - logger.debug(f"Error copying file: {str(copy_error)}") + logger.debug(f"Error copying file: {copy_error}", exc_info=True) error_dialog = QMessageBox(self) error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setWindowTitle("Setup Error") - error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}") + error_dialog.setText(f"Error copying yt-dlp to app directory: {copy_error}") error_dialog.setStyleSheet( """ QMessageBox { @@ -448,11 +448,11 @@ class YtdlpSetupDialog(QDialog): ) error_dialog.exec() except Exception as e: - logger.debug(f"Exception during verification: {str(e)}") + logger.debug(f"Exception during verification: {e}", exc_info=True) error_dialog = QMessageBox(self) error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setWindowTitle("Error") - error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}") + error_dialog.setText(f"Error verifying yt-dlp executable: {e}") error_dialog.setStyleSheet( """ QMessageBox { @@ -492,7 +492,7 @@ def check_ytdlp_binary() -> Optional[Path]: os.chmod(exe_path, 0o755) logger.info(f"Fixed permissions on yt-dlp at {exe_path}") except Exception as e: - logger.warning(f"Could not set executable permissions on {exe_path}: {e}") + logger.exception(f"Could not set executable permissions on {exe_path}: {e}") return exe_path # If not found in app directory, check if yt-dlp is available in PATH @@ -517,7 +517,7 @@ def check_ytdlp_binary() -> Optional[Path]: logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}") return Path(yt_dlp_path) except Exception as e: - logger.error(f"Error checking for yt-dlp in PATH: {e}") + logger.exception(f"Error checking for yt-dlp in PATH: {e}") # We're only interested in our app-specific installation or system PATH return None diff --git a/src/gui/ytsage_gui_dialogs/__init__.py b/src/gui/ytsage_gui_dialogs/__init__.py index 5d60dd3..fe233f3 100644 --- a/src/gui/ytsage_gui_dialogs/__init__.py +++ b/src/gui/ytsage_gui_dialogs/__init__.py @@ -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", diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py index c45e39c..d38c8c2 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py @@ -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"Version {getattr(self._parent, 'version', '4.8.0b')}" + f"Version {getattr(self._parent, 'version', '4.8.3')}" ) 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" ({cache_time})" # 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" ({cache_time})" # Increased from 9px diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py index 778fbc9..4f166f0 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py @@ -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() diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py index 0265e9b..a395103 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py @@ -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) diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py index 0fc0ef1..bac857d 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py @@ -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() diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py index 0638e2b..7b94f44 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py @@ -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 diff --git a/src/gui/ytsage_gui_format_table.py b/src/gui/ytsage_gui_format_table.py index 828cf3c..012c14e 100644 --- a/src/gui/ytsage_gui_format_table.py +++ b/src/gui/ytsage_gui_format_table.py @@ -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 diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py index b0da1ed..5134433 100644 --- a/src/gui/ytsage_gui_main.py +++ b/src/gui/ytsage_gui_main.py @@ -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) diff --git a/src/gui/ytsage_gui_video_info.py b/src/gui/ytsage_gui_video_info.py index 362524d..56353ca 100644 --- a/src/gui/ytsage_gui_video_info.py +++ b/src/gui/ytsage_gui_video_info.py @@ -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 diff --git a/src/utils/ytsage_config_manager.py b/src/utils/ytsage_config_manager.py new file mode 100644 index 0000000..062d358 --- /dev/null +++ b/src/utils/ytsage_config_manager.py @@ -0,0 +1,202 @@ +""" +Config Manager Module +===================== + +This module provides **thread-safe** centralized management for application +configuration in YTSage. It handles reading, writing, and managing settings +stored in a JSON file, with support for nested keys via dot notation. + +Thread safety is ensured using a reentrant lock (`RLock`), so multiple threads +can safely access or modify settings concurrently. + +Features +-------- +- Thread-safe operations for getting, setting, and deleting configuration values. +- 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 dot-separated keys. +- Provides safe error handling with logging instead of raising exceptions. +- Persists updates back to disk automatically. + +Usage +----- +from src.utils.ytsage_config_manager import ConfigManager + +# Load settings (auto-loads if not already loaded) +download_path = ConfigManager.get("download_path") + +# Update a value +ConfigManager.set("download_path", "D:/Downloads") + +# Retrieve nested value +last_check = ConfigManager.get("cached_versions.ytdlp.last_check") + +# Delete a key +ConfigManager.delete("cached_versions.ffmpeg.path") + +Design Notes +------------ +- Settings are stored in `ConfigManager.settings` (a dict). +- Default values are defined in `ConfigManager.default_config`. +- All modifications trigger a save (`_save`) to keep JSON in sync. +- Logs actions and errors using the app's central logger. +- Uses `RLock` to allow safe concurrent access from multiple threads. + +Exceptions +---------- +- Any issues during file I/O (permissions, disk errors, JSON corruption) + are caught and logged. The application continues running with defaults + when possible. +""" + +import json +import threading +from typing import Any + +from src.utils.ytsage_constants import APP_CONFIG_FILE, USER_HOME_DIR +from src.utils.ytsage_logger import logger + + +class ConfigManager: + """ + Thread-safe configuration manager for YTSage. + + Provides methods to load, save, get, set, and delete settings stored in a JSON file. + Supports nested keys via dot notation and automatically persists changes. + """ + + _lock = threading.RLock() + _config_file = APP_CONFIG_FILE + _settings: dict[str, Any] = {} + _default_config = { + "download_path": str(USER_HOME_DIR / "Downloads"), + "speed_limit_value": None, + "speed_limit_unit_index": 0, + "cookie_file_path": None, + "last_used_cookie_file": None, + "auto_update_ytdlp": True, + "auto_update_frequency": "daily", + "last_update_check": 0, + "cached_versions": { + "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, + "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, + }, + } + + @classmethod + def _load(cls) -> None: + """ + Loads configuration settings from a JSON file if it exists and is valid. + If the file is missing or corrupt, loads default settings and creates or overwrites the config file as needed. + Logs actions and errors during the process. + """ + with cls._lock: + if cls._config_file.exists(): + try: + with open(cls._config_file, "r", encoding="utf-8") as f: + cls._settings = json.load(f) + logger.info("Config loaded from file.") + except json.JSONDecodeError: + cls._settings = cls._default_config.copy() + logger.warning("Config file corrupt, loaded defaults.") + else: + cls._settings = cls._default_config.copy() + cls._save() + logger.info("Config file not found, created default config.") + + @classmethod + def _save(cls) -> None: + """ + Save current settings to JSON file. + + Note: + May raise exceptions if the file cannot be written due to permission issues, + disk errors, or other I/O problems. + """ + with cls._lock: + try: + with open(cls._config_file, "w", encoding="utf-8") as f: + json.dump(cls._settings, f, indent=4) + logger.debug("Config saved to file.") + except (OSError, PermissionError) as e: + logger.exception(f"Failed to save config: {e}") + except Exception as e: + logger.exception(f"Unexpected error while saving config: {e}") + + @classmethod + def get(cls, key: str) -> Any: + """ + Retrieve a configuration value using a dotted key notation. + Args: + key (str): The dotted key string representing the path to the desired setting (e.g., "database.host"). + Any: The value associated with the given key, or None if the key does not exist. + Notes: + - If the configuration settings are not loaded, this method will load them before attempting retrieval. + - If any part of the dotted key path is missing, None is returned and a debug message is logged. + """ + with cls._lock: + if not cls._settings: + cls._load() + parts = key.split(".") + value = cls._settings + for part in parts: + if isinstance(value, dict) and part in value: + value = value[part] + else: + logger.debug(f"Config key '{key}' not found.") + return None + return value + + @classmethod + def set(cls, key: str, value: Any) -> None: + """ + Sets a configuration value for a given key. + If the configuration settings are not loaded, loads them first. + Supports nested keys using dot notation (e.g., "database.host"). + Updates the configuration dictionary with the provided value, + saves the updated settings, and logs the change. + Args: + key (str): The configuration key, possibly nested using dots. + value (Any): The value to set for the specified key. + Returns: + None + """ + with cls._lock: + if not cls._settings: + cls._load() + parts = key.split(".") + d = cls._settings + for part in parts[:-1]: + d = d.setdefault(part, {}) + d[parts[-1]] = value + cls._save() + logger.info(f"Config key '{key}' set to '{value}'.") + + @classmethod + def delete(cls, key: str) -> None: + """ + Deletes a configuration key from the settings. + If the key is nested (dot-separated), traverses the settings dictionary accordingly. + If the key exists, removes it and saves the updated settings. + Logs the deletion or if the key was not found. + Args: + key (str): The dot-separated configuration key to delete. + Returns: + None + """ + with cls._lock: + if not cls._settings: + cls._load() + parts = key.split(".") + d = cls._settings + for part in parts[:-1]: + if part not in d: + logger.debug(f"Config key '{key}' not found for deletion.") + return + d = d[part] + if parts[-1] in d: + d.pop(parts[-1], None) + cls._save() + logger.info(f"Config key '{key}' deleted.") + else: + logger.debug(f"Config key '{key}' not found for deletion.") diff --git a/src/utils/ytsage_constants.py b/src/utils/ytsage_constants.py index 62a0d7e..882584d 100644 --- a/src/utils/ytsage_constants.py +++ b/src/utils/ytsage_constants.py @@ -59,14 +59,18 @@ SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3") OS_NAME: str = platform.system() # Windows ; Darwin ; Linux +IS_FROZEN = getattr(sys, "frozen", False) USER_HOME_DIR: Path = Path.home() # OS Specific Constants if OS_NAME == "Windows": OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" - # APP_PATH will be from system environment path or fallback to Path.home() - APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage" + if IS_FROZEN: + APP_DIR: Path = Path(sys.executable).parent + else: + # APP_PATH will be from system environment path or fallback to Path.home() + APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage" APP_BIN_DIR: Path = APP_DIR / "bin" APP_DATA_DIR: Path = APP_DIR / "data" APP_LOG_DIR: Path = APP_DIR / "logs" @@ -74,9 +78,6 @@ if OS_NAME == "Windows": YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe" - - # Documentation URLs - YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options" SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW @@ -84,7 +85,10 @@ elif OS_NAME == "Darwin": # macOS _mac_version = platform.mac_ver()[0] OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS" - APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage" + if IS_FROZEN: + APP_DIR: Path = Path(sys.executable).parent + else: + APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage" APP_BIN_DIR: Path = APP_DIR / "bin" APP_DATA_DIR: Path = APP_DIR / "data" APP_LOG_DIR: Path = APP_DIR / "logs" @@ -92,9 +96,6 @@ elif OS_NAME == "Darwin": # macOS YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" - - # Documentation URLs - YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options" SUBPROCESS_CREATIONFLAGS: int = 0 @@ -102,7 +103,10 @@ elif OS_NAME == "Darwin": # macOS else: # Linux and other UNIX-like OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" - APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage" + if IS_FROZEN: + APP_DIR: Path = Path(sys.executable).parent + else: + APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage" APP_BIN_DIR: Path = APP_DIR / "bin" APP_DATA_DIR: Path = APP_DIR / "data" APP_LOG_DIR: Path = APP_DIR / "logs" @@ -110,12 +114,11 @@ else: # Linux and other UNIX-like YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp" - - # Documentation URLs - YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options" SUBPROCESS_CREATIONFLAGS: int = 0 +# Documentation URLs +YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options" # ffmpeg download links FFMPEG_7Z_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z" @@ -124,6 +127,7 @@ FFMPEG_ZIP_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/downloa if __name__ == "__main__": # If this file is run directly, print directory information; if imported, create the necessary directories for the application. + # for debug, to check os specific variable which can be different based on os. info = { "OS_NAME": OS_NAME, "OS_FULL_NAME": OS_FULL_NAME, @@ -135,7 +139,6 @@ if __name__ == "__main__": "APP_CONFIG_FILE": str(APP_CONFIG_FILE), "YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL, "YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH, - "YTDLP_DOCS_URL": YTDLP_DOCS_URL, "SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS, } for key, value in info.items(): diff --git a/src/utils/ytsage_logger.py b/src/utils/ytsage_logger.py new file mode 100644 index 0000000..a066454 --- /dev/null +++ b/src/utils/ytsage_logger.py @@ -0,0 +1,56 @@ +""" +YTSage centralized logging with loguru. + +- This module provides centralized logging configuration for the entire YTSage application. +- Two log files: ytsage.log (all logs) & ytsage_error.log (errors only). +""" + +import sys + +from loguru import logger + +from src.utils.ytsage_constants import APP_LOG_DIR, IS_FROZEN + +# Separate configs for each handler +CONSOLE_CONFIG = { + "sink": sys.stdout if sys.stdout else sys.stderr, + "level": "INFO", + "colorize": True, + "enqueue": True, +} + +ALL_LOGS_CONFIG = { + "sink": APP_LOG_DIR / "ytsage.log", + "level": "DEBUG", + "rotation": "10 MB", + "retention": "14 days", + "compression": "zip", + "enqueue": True, +} + +ERROR_LOGS_CONFIG = { + "sink": APP_LOG_DIR / "ytsage_error.log", + "level": "ERROR", + "rotation": "5 MB", + "retention": "30 days", + "compression": "zip", + "enqueue": True, +} + + +# Logger initialization +def init_logger() -> None: + """Configure loguru logger using separate configs for each handler.""" + logger.remove() # Remove default loguru handler + + if not IS_FROZEN: + logger.add(**CONSOLE_CONFIG) + logger.add(**ALL_LOGS_CONFIG) + logger.add(**ERROR_LOGS_CONFIG) + + logger.info("YTSage logger initialized") + + +init_logger() + +__all__ = ["logger"]