v4.9.0 - Refactor (#41)

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

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

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

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

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

- imporve: return type hint for function.

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

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

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

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

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

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

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

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

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

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

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

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

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

download_path = ConfigManager.get("download_path")

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

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

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

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

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

* remove: import check, it should always work.

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

* chang back to checking import for ytdlp

* Bump version to 4.8.1

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

* Update asset paths in build workflows

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

* Bump version to 4.8.2

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

* Fix asset include path in Windows build workflow

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

* Update release tag examples in CI/CD README

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

* Fix include-files mapping in Windows build workflow

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

* Remove redundant comments in build-windows workflow

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

* Refactor Windows build to use cx_Freeze setup script

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

* Bump version to 4.8.3

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

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

---------

Co-authored-by: Your Name <mohamed.mohamed112@ai.mnu.edu.eg>
This commit is contained in:
Viren Hirpara
2025-09-10 01:31:19 +05:30
committed by GitHub
parent c140acef5a
commit e9de913b47
23 changed files with 825 additions and 714 deletions
+4 -4
View File
@@ -126,14 +126,14 @@ To add new platform packages:
```bash ```bash
# For a new release # For a new release
git tag v4.8.0 git tag v4.8.1
git push origin v4.8.0 git push origin v4.8.1
# For a patch release # For a patch release
git tag v4.8.1 git tag v4.8.1
git push origin v4.8.1 git push origin v4.8.1
# To delete a tag (if needed) # To delete a tag (if needed)
git tag -d v4.8.0 git tag -d v4.8.1
git push origin :refs/tags/v4.8.0 git push origin :refs/tags/v4.8.1
``` ```
+3 -3
View File
@@ -122,7 +122,7 @@ jobs:
], ],
include_files=[ include_files=[
("src", "src"), ("src", "src"),
("assets", "assets"), ("assets", "lib/assets"),
("ytsage.desktop", "share/applications/ytsage.desktop"), ("ytsage.desktop", "share/applications/ytsage.desktop"),
("assets/branding/icons/icon.png", "share/pixmaps/ytsage.png"), ("assets/branding/icons/icon.png", "share/pixmaps/ytsage.png"),
], ],
@@ -185,8 +185,8 @@ jobs:
# Remove screenshots to reduce size before packaging # Remove screenshots to reduce size before packaging
build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true) build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true)
if [ -n "$build_dir" ] && [ -d "$build_dir/assets/branding/screenshots" ]; then if [ -n "$build_dir" ] && [ -d "$build_dir/lib/assets/branding/screenshots" ]; then
rm -rf "$build_dir/assets/branding/screenshots" rm -rf "$build_dir/lib/assets/branding/screenshots"
echo "Removed screenshots folder from $build_dir" echo "Removed screenshots folder from $build_dir"
fi fi
+5 -5
View File
@@ -123,7 +123,7 @@ jobs:
], ],
include_files=[ include_files=[
("src", "src"), ("src", "src"),
("assets", "assets"), ("assets", "lib/assets"),
], ],
) )
@@ -171,8 +171,8 @@ jobs:
if [ -z "$app_path" ]; then if [ -z "$app_path" ]; then
app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true) app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true)
fi fi
if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then
rm -rf "$app_path/Contents/Resources/assets/branding/screenshots" rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots"
echo "Removed screenshots folder from .app bundle at $app_path" echo "Removed screenshots folder from .app bundle at $app_path"
fi fi
echo "Post-bdist_mac directory listing:" echo "Post-bdist_mac directory listing:"
@@ -207,8 +207,8 @@ jobs:
app_base="$(basename "$app_path")" app_base="$(basename "$app_path")"
app_parent="$(dirname "$app_path")" app_parent="$(dirname "$app_path")"
# Ensure screenshots folder is not shipped # Ensure screenshots folder is not shipped
if [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then if [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then
rm -rf "$app_path/Contents/Resources/assets/branding/screenshots" rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots"
echo "Removed screenshots folder from .app bundle at $app_path" echo "Removed screenshots folder from .app bundle at $app_path"
fi fi
(cd "$app_parent" && zip -r "${GITHUB_WORKSPACE}/artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip" "$app_base") (cd "$app_parent" && zip -r "${GITHUB_WORKSPACE}/artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip" "$app_base")
+84 -26
View File
@@ -62,6 +62,79 @@ jobs:
echo "VERSION=$version" >> $env:GITHUB_ENV echo "VERSION=$version" >> $env:GITHUB_ENV
Write-Host "Prepared build variables for version: $version" 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) - name: Build Standard Version (ZIP)
shell: powershell shell: powershell
run: | run: |
@@ -74,21 +147,12 @@ jobs:
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force } if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force }
if (Test-Path "dist") { Remove-Item "dist" -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) # Build executable using setup script
python -m cx_Freeze main.py ` python setup_cxfreeze.py build_exe --build-exe "dist\YTSage"
--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"
# Remove screenshots folder to reduce build size # Remove screenshots folder to reduce build size
if (Test-Path "dist\YTSage\assets\branding\screenshots") { if (Test-Path "dist\YTSage\lib\assets\branding\screenshots") {
Remove-Item "dist\YTSage\assets\branding\screenshots" -Recurse -Force Remove-Item "dist\YTSage\lib\assets\branding\screenshots" -Recurse -Force
Write-Host "Removed screenshots folder from standard build" 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\exe.*") { Remove-Item "build\exe.*" -Recurse -Force }
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -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) # Create modified setup script for FFmpeg version
python -m cx_Freeze main.py ` (Get-Content "setup_cxfreeze.py") -replace 'YTSage-v\{version\}\.exe', 'YTSage-v{version}-ffmpeg.exe' | Set-Content "setup_cxfreeze_ffmpeg.py"
--target-dir "dist\YTSage-FFmpeg" `
--base-name Win32GUI ` # Build executable with FFmpeg using setup script
--icon "assets\branding\icons\YTSage.ico" ` python setup_cxfreeze_ffmpeg.py build_exe --build-exe "dist\YTSage-FFmpeg"
--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"
# Remove screenshots folder to reduce build size # Remove screenshots folder to reduce build size
if (Test-Path "dist\YTSage-FFmpeg\assets\branding\screenshots") { if (Test-Path "dist\YTSage-FFmpeg\lib\assets\branding\screenshots") {
Remove-Item "dist\YTSage-FFmpeg\assets\branding\screenshots" -Recurse -Force Remove-Item "dist\YTSage-FFmpeg\lib\assets\branding\screenshots" -Recurse -Force
Write-Host "Removed screenshots folder from FFmpeg build" Write-Host "Removed screenshots folder from FFmpeg build"
} }
+2 -5
View File
@@ -2,11 +2,8 @@ import sys
from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox
from src.core.ytsage_logging import logger from src.utils.ytsage_logger import logger
from src.core.ytsage_yt_dlp import ( # Import the new yt-dlp setup functions from src.core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp # Import the new yt-dlp setup functions
check_ytdlp_binary,
setup_ytdlp,
)
from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
+1 -1
View File
@@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader
A modern, user-friendly YouTube video downloader built with PySide6. A modern, user-friendly YouTube video downloader built with PySide6.
""" """
__version__ = "4.8.0b" __version__ = "4.8.3"
__author__ = "oop7" __author__ = "oop7"
+29 -30
View File
@@ -7,9 +7,9 @@ from pathlib import Path
from PySide6.QtCore import QObject, QThread, Signal 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.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
from src.utils.ytsage_logger import logger
try: try:
import yt_dlp # Keep yt_dlp import here - only downloader uses it. import yt_dlp # Keep yt_dlp import here - only downloader uses it.
@@ -38,6 +38,7 @@ class DownloadThread(QThread):
error_signal = Signal(str) error_signal = Signal(str)
file_exists_signal = Signal(str) # New signal for file existence 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
update_details = Signal(str) # New signal for filename, speed, ETA
def __init__( def __init__(
self, self,
@@ -97,9 +98,10 @@ class DownloadThread(QThread):
try: try:
file_path.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
except Exception as e: 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: 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: def cleanup_subtitle_files(self) -> None:
"""Delete subtitle files after they have been merged into the video file""" """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}") logger.debug(f"Deleted subtitle file: {path.name}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error deleting subtitle file {path}: {e}") logger.exception(f"Error deleting subtitle file {path}: {e}")
return False return False
try: try:
@@ -130,7 +132,7 @@ class DownloadThread(QThread):
else: else:
logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files") logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files")
except Exception as e: 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: def check_file_exists(self) -> bool | None:
"""Check if the file already exists before downloading""" """Check if the file already exists before downloading"""
@@ -138,18 +140,21 @@ class DownloadThread(QThread):
logger.debug("Starting file existence check") logger.debug("Starting file existence check")
# Use yt-dlp to get the filename without downloading, suppressing warnings # Use yt-dlp to get the filename without downloading, suppressing warnings
ydl_opts_check = { ydl_opts_check = {
"logger": logger, # passed app logger
"quiet": True, "quiet": True,
"skip_download": True, "skip_download": True,
"no_warnings": True, # <-- Suppress warnings during check "no_warnings": True, # <-- Suppress warnings during check
"ignoreerrors": True, # Also ignore other potential errors during this 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 "format": (self.format_id if self.format_id else "best"), # Use selected format or best
} }
if self.cookie_file: if self.cookie_file:
ydl_opts_check["cookiefile"] = str(self.cookie_file) ydl_opts_check["cookiefile"] = str(self.cookie_file)
elif self.browser_cookies: elif self.browser_cookies:
ydl_opts_check["cookiesfrombrowser"] = (self.browser_cookies.split(':')[0], ydl_opts_check["cookiesfrombrowser"] = (
self.browser_cookies.split(':')[1] if ':' in self.browser_cookies else None) self.browser_cookies.split(":")[0],
self.browser_cookies.split(":")[1] if ":" in self.browser_cookies else None,
)
if YT_DLP_AVAILABLE: if YT_DLP_AVAILABLE:
with yt_dlp.YoutubeDL(ydl_opts_check) as ydl: with yt_dlp.YoutubeDL(ydl_opts_check) as ydl:
@@ -178,10 +183,7 @@ class DownloadThread(QThread):
return False # Proceed with download attempt return False # Proceed with download attempt
except Exception as e: except Exception as e:
logger.debug(f"Error checking file existence: {str(e)}") logger.exception(f"Error checking file existence: {e}")
import traceback
traceback.print_exc()
return None return None
def _build_yt_dlp_command(self) -> list: def _build_yt_dlp_command(self) -> list:
@@ -201,6 +203,7 @@ class DownloadThread(QThread):
try: try:
if YT_DLP_AVAILABLE: if YT_DLP_AVAILABLE:
ydl_opts = { ydl_opts = {
"logger": logger,
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"skip_download": True, "skip_download": True,
@@ -214,7 +217,7 @@ class DownloadThread(QThread):
logger.debug(f"Detected audio-only format for ID: {clean_format_id}") logger.debug(f"Detected audio-only format for ID: {clean_format_id}")
break break
except Exception as e: 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 # For audio-only formats, don't try to merge with video
if is_audio_format: if is_audio_format:
@@ -229,12 +232,9 @@ class DownloadThread(QThread):
try: try:
format_ext = None format_ext = None
logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})") logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
if YT_DLP_AVAILABLE: if YT_DLP_AVAILABLE:
ydl_opts = { ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True, "logger": logger}
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(self.url, download=False) or {} info = ydl.extract_info(self.url, download=False) or {}
# Look for the clean format ID first # Look for the clean format ID first
@@ -254,7 +254,7 @@ class DownloadThread(QThread):
# Ensure output matches the selected format - only for video formats # Ensure output matches the selected format - only for video formats
cmd.extend(["--merge-output-format", format_ext]) cmd.extend(["--merge-output-format", format_ext])
except Exception as e: 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 # If we can't determine the format, don't specify merge-output-format
pass pass
else: else:
@@ -272,7 +272,7 @@ class DownloadThread(QThread):
else: else:
output_template = f"{base_path}/%(title)s_%(resolution)s.%(ext)s" 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 # Add common options
cmd.append("--force-overwrites") cmd.append("--force-overwrites")
@@ -295,7 +295,7 @@ class DownloadThread(QThread):
lang_code = sub_selection.split(" - ")[0] lang_code = sub_selection.split(" - ")[0]
lang_codes.append(lang_code) lang_codes.append(lang_code)
except Exception as e: 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: if lang_codes:
cmd.extend(["--sub-langs", ",".join(lang_codes)]) cmd.extend(["--sub-langs", ",".join(lang_codes)])
@@ -366,7 +366,7 @@ class DownloadThread(QThread):
self.initial_subtitle_files.add(file) self.initial_subtitle_files.add(file)
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download") logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
except Exception as e: 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: if self.use_direct_command:
# Use direct CLI command instead of Python API # Use direct CLI command instead of Python API
@@ -377,10 +377,8 @@ class DownloadThread(QThread):
except Exception as e: except Exception as e:
# Catch errors during setup # Catch errors during setup
self.error_signal.emit(f"Critical error in download thread: {str(e)}") logger.critical(f"Critical error in download thread: {e}", exc_info=True)
import traceback self.error_signal.emit(f"Critical error in download thread: {e}")
traceback.print_exc()
def _run_direct_command(self) -> None: def _run_direct_command(self) -> None:
"""Run yt-dlp as a direct command line process instead of using Python API.""" """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() self.cleanup_partial_files()
except Exception as e: 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() self.cleanup_partial_files()
def _parse_output_line(self, line) -> None: def _parse_output_line(self, line) -> None:
@@ -513,7 +512,7 @@ class DownloadThread(QThread):
else: else:
self.status_signal.emit(f"⏬ Downloading...") self.status_signal.emit(f"⏬ Downloading...")
except Exception as e: 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 self.status_signal.emit("⚡ Downloading...") # Fallback status
return # Don't process this line further for speed/ETA return # Don't process this line further for speed/ETA
@@ -538,7 +537,7 @@ class DownloadThread(QThread):
# Clean up the path - remove any duplicated directory paths # Clean up the path - remove any duplicated directory paths
# Sometimes yt-dlp output contains malformed paths like "dir: dir/file" # 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 # Look for pattern like "C:\path: C:\path\file" and extract the latter
colon_parts = subtitle_file.split(": ") colon_parts = subtitle_file.split(": ")
if len(colon_parts) > 1: if len(colon_parts) > 1:
@@ -607,7 +606,7 @@ class DownloadThread(QThread):
self.update_details.emit(status) self.update_details.emit(status)
except Exception as e: except Exception as e:
# If parsing fails, just show basic status (maybe log the error) # 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 pass # Keep basic status emission below if needed, or emit generic details
# Check for post-processing # Check for post-processing
+21 -15
View File
@@ -7,7 +7,7 @@ from pathlib import Path
import requests import requests
from src.core.ytsage_logging import logger from src.utils.ytsage_logger import logger
from src.utils.ytsage_constants import ( from src.utils.ytsage_constants import (
FFMPEG_7Z_DOWNLOAD_URL, FFMPEG_7Z_DOWNLOAD_URL,
FFMPEG_7Z_SHA256_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}%") progress_callback(f"⚡ Downloading FFmpeg components... {progress}%")
return True return True
except requests.RequestException as e: except requests.RequestException as e:
logger.info(f"Download error: {str(e)}") logger.info(f"Download error: {e}")
return False return False
@@ -80,7 +80,7 @@ def verify_sha256(file_path, expected_hash_url) -> bool:
logger.info(f"Actual: {actual_hash}") logger.info(f"Actual: {actual_hash}")
return False return False
except Exception as e: except Exception as e:
logger.info(f"⚠️ SHA-256 verification error: {str(e)}") logger.info(f"⚠️ SHA-256 verification error: {e}")
return False return False
@@ -128,7 +128,7 @@ def get_ffmpeg_path() -> str | Path:
ffmpeg_path = result.stdout.strip() ffmpeg_path = result.stdout.strip()
return ffmpeg_path return ffmpeg_path
except Exception as e: 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 # If not found in PATH, check the installation directory
ffmpeg_install_path = get_ffmpeg_install_path() ffmpeg_install_path = get_ffmpeg_install_path()
@@ -171,7 +171,7 @@ def check_ffmpeg_installed() -> bool:
return True return True
return False return False
except Exception as e: except Exception as e:
logger.info(f"FFmpeg check error: {str(e)}") logger.info(f"FFmpeg check error: {e}")
return False return False
@@ -219,7 +219,7 @@ def install_ffmpeg_windows() -> bool:
timeout=300, timeout=300,
) # 5-minute timeout ) # 5-minute timeout
except Exception as e: 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 use_7zip = False
else: else:
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...") logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
@@ -236,7 +236,8 @@ def install_ffmpeg_windows() -> bool:
temp_file, temp_file,
progress_callback=lambda msg: logger.debug(msg), 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...") logger.info("Extracting FFmpeg components from zip archive...")
try: try:
@@ -245,7 +246,8 @@ def install_ffmpeg_windows() -> bool:
with zipfile.ZipFile(temp_file, "r") as zip_ref: with zipfile.ZipFile(temp_file, "r") as zip_ref:
zip_ref.extractall(extract_dir) zip_ref.extractall(extract_dir)
except Exception as e: except Exception as e:
raise Exception(f"Extraction failed: {str(e)}") logger.exception(f"Extraction failed: {e}")
return False
logger.info("Configuring system paths...") logger.info("Configuring system paths...")
# Add to System Path # Add to System Path
@@ -265,13 +267,14 @@ def install_ffmpeg_windows() -> bool:
# Verify installation # Verify installation
if not check_ffmpeg_installed(): 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!") logger.info("FFmpeg installation completed successfully!")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error installing FFmpeg: {str(e)}") logger.exception(f"Error installing FFmpeg: {e}")
return False return False
@@ -298,12 +301,13 @@ def install_ffmpeg_macos() -> bool:
# Verify installation # Verify installation
if not check_ffmpeg_installed(): if not check_ffmpeg_installed():
raise Exception("FFmpeg installation verification failed") logger.error("FFmpeg installation verification failed")
return False
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error installing FFmpeg: {str(e)}") logger.exception(f"Error installing FFmpeg: {e}")
return False return False
@@ -329,16 +333,18 @@ def install_ffmpeg_linux() -> bool:
# Universal snap package # Universal snap package
subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300) subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300)
else: else:
raise Exception("No supported package manager found") logger.error("No supported package manager found")
return False
# Verify installation # Verify installation
if not check_ffmpeg_installed(): if not check_ffmpeg_installed():
raise Exception("FFmpeg installation verification failed") logger.error("FFmpeg installation verification failed")
return False
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error installing FFmpeg: {str(e)}") logger.exception(f"Error installing FFmpeg: {e}")
return False return False
-238
View File
@@ -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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
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"]
+108 -80
View File
@@ -4,28 +4,13 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
from importlib.metadata import PackageNotFoundError
from pathlib import Path 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 import requests
from packaging import version from packaging import version
from src.core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path 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.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import ( from src.utils.ytsage_constants import (
APP_CONFIG_FILE, APP_CONFIG_FILE,
@@ -35,6 +20,25 @@ from src.utils.ytsage_constants import (
YTDLP_APP_BIN_PATH, YTDLP_APP_BIN_PATH,
YTDLP_DOWNLOAD_URL, 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 # Cache for version information to avoid delays
_version_cache = { _version_cache = {
@@ -108,7 +112,7 @@ def load_version_cache_from_config() -> None:
if tool_name in _version_cache: if tool_name in _version_cache:
_version_cache[tool_name].update(cache_data) _version_cache[tool_name].update(cache_data)
except Exception as e: 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: def save_version_cache_to_config() -> None:
@@ -118,7 +122,7 @@ def save_version_cache_to_config() -> None:
config["cached_versions"] = _version_cache.copy() config["cached_versions"] = _version_cache.copy()
save_config(config) save_config(config)
except Exception as e: 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: def get_ytdlp_version_cached() -> str:
@@ -140,7 +144,7 @@ def get_ytdlp_version_cached() -> str:
return version_info return version_info
except Exception as e: 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" return "Error getting version"
@@ -164,7 +168,7 @@ def get_ffmpeg_version_cached() -> str:
return version_info return version_info
except Exception as e: 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" return "Error getting version"
@@ -182,7 +186,7 @@ def refresh_version_cache(force=False) -> bool:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error refreshing version cache: {e}") logger.exception(f"Error refreshing version cache: {e}")
return False return False
@@ -215,7 +219,7 @@ def get_ytdlp_version_direct(yt_dlp_path=None) -> str:
else: else:
return "Error getting version" return "Error getting version"
except Exception as e: 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" return "Error getting version"
@@ -269,10 +273,10 @@ def get_ffmpeg_version_direct() -> str:
return "Unknown version" return "Unknown version"
return "Not found" return "Not found"
except Exception as e: 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" return "Not found"
except Exception as e: except Exception as e:
logger.error(f"Error getting FFmpeg version: {e}") logger.exception(f"Error getting FFmpeg version: {e}")
return "Error getting version" return "Error getting version"
@@ -308,7 +312,7 @@ def load_config() -> dict:
config[key] = value config[key] = value
return config return config
except (json.JSONDecodeError, UnicodeError, Exception) as e: 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 # If config file is corrupted, create a new one with defaults
save_config(default_config) save_config(default_config)
@@ -322,7 +326,7 @@ def save_config(config) -> bool:
json.dump(config, f, ensure_ascii=False, indent=2) json.dump(config, f, ensure_ascii=False, indent=2)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error saving config: {e}") logger.exception(f"Error saving config: {e}")
return False return False
@@ -342,7 +346,7 @@ def check_ffmpeg() -> bool:
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}" os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error updating PATH: {e}") logger.exception(f"Error updating PATH: {e}")
return False return False
# For macOS, check common paths # 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', '')}" os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error updating PATH: {e}") logger.exception(f"Error updating PATH: {e}")
continue continue
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error checking FFmpeg: {e}") logger.exception(f"Error checking FFmpeg: {e}")
return False return False
@@ -381,7 +385,7 @@ def load_saved_path(main_window_instance) -> None:
main_window_instance.last_path = saved_path main_window_instance.last_path = saved_path
return return
except (json.JSONDecodeError, UnicodeError) as e: 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 # If config file is corrupted, try to remove it
try: try:
APP_CONFIG_FILE.unlink(missing_ok=True) 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() main_window_instance.last_path = tempfile.gettempdir()
except Exception as e: 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() main_window_instance.last_path = tempfile.gettempdir()
@@ -409,7 +413,7 @@ def save_path(main_window_instance, path) -> bool:
try: try:
Path(path).mkdir(exist_ok=True) Path(path).mkdir(exist_ok=True)
except Exception as e: except Exception as e:
logger.error(f"Error creating directory: {e}") logger.exception(f"Error creating directory: {e}")
return False return False
if not os.access(path, os.W_OK): if not os.access(path, os.W_OK):
@@ -423,7 +427,7 @@ def save_path(main_window_instance, path) -> bool:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error saving settings: {e}") logger.exception(f"Error saving settings: {e}")
return False return False
@@ -484,13 +488,13 @@ def update_yt_dlp() -> bool:
logger.info("yt-dlp binary successfully updated") logger.info("yt-dlp binary successfully updated")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error replacing yt-dlp binary: {e}") logger.exception(f"Error replacing yt-dlp binary: {e}")
return False return False
else: else:
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}") logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error downloading yt-dlp update: {e}") logger.exception(f"Error downloading yt-dlp update: {e}")
return False return False
else: else:
# We're using a system-installed yt-dlp, use pip to update # We're using a system-installed yt-dlp, use pip to update
@@ -540,9 +544,9 @@ def update_yt_dlp() -> bool:
else: else:
logger.info(f"Failed to get latest version info: HTTP {response.status_code}") logger.info(f"Failed to get latest version info: HTTP {response.status_code}")
except Exception as e: 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: 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 return False
@@ -573,7 +577,7 @@ def should_check_for_auto_update() -> bool:
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error checking auto-update schedule: {e}") logger.exception(f"Error checking auto-update schedule: {e}")
return False return False
@@ -602,9 +606,7 @@ def check_and_update_ytdlp_auto() -> bool:
logger.info(f"Latest yt-dlp version: {latest_version}") logger.info(f"Latest yt-dlp version: {latest_version}")
# Compare versions # Compare versions
from packaging import version as version_parser if version.parse(latest_version) > version.parse(current_version):
if version_parser.parse(latest_version) > version_parser.parse(current_version):
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...") logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
# Perform the update # Perform the update
@@ -630,11 +632,11 @@ def check_and_update_ytdlp_auto() -> bool:
logger.info(f"Network error during auto-update check: {e}") logger.info(f"Network error during auto-update check: {e}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error during auto-update check: {e}") logger.exception(f"Error during auto-update check: {e}")
return False return False
except Exception as e: 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 return False
@@ -657,7 +659,7 @@ def update_auto_update_settings(enabled, frequency) -> bool:
save_config(config) save_config(config)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error updating auto-update settings: {e}") logger.exception(f"Error updating auto-update settings: {e}")
return False return False
@@ -671,63 +673,89 @@ def parse_yt_dlp_error(error_message: str) -> str:
Returns: Returns:
str: A user-friendly error message with actionable advice str: A user-friendly error message with actionable advice
""" """
error_str = str(error_message).lower() error_str = error_message.lower()
# Private video errors # Private video errors
if any(keyword in error_str for keyword in ['private video', 'login_required', 'sign in if you']): 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" return (
"Go to 'Custom Options''Login with Cookies''Extract cookies from browser' to authenticate.") "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 # Age-restricted content
if any(keyword in error_str for keyword in ['age restricted', 'age-restricted', 'confirm your age']): 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" return (
"Use 'Custom Options''Login with Cookies' to authenticate with your account.") "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 # 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']): if any(
return ("This video is not available in your region (geo-blocked).\n" keyword in error_str
"You may need to use a VPN or the video might be restricted in your country.") 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 # Removed/deleted videos
if any(keyword in error_str for keyword in ['video unavailable', 'this video has been removed', 'video does not exist']): 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" return (
"The video may have been deleted by the uploader or removed due to policy violations.") "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 # Live stream errors
if any(keyword in error_str for keyword in ['live stream', 'livestream', 'is live']): 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" return (
"Wait for the stream to end, then try downloading the archived version.") "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 # Playlist errors
if any(keyword in error_str for keyword in ['playlist', 'no entries']): 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" return (
"Check if the playlist exists and is publicly accessible.") "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 # Network/connection errors
if any(keyword in error_str for keyword in ['network error', 'connection', 'timeout', 'unable to download']): 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" return (
"If the problem persists, the video server might be temporarily unavailable.") "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 # Invalid URL
if any(keyword in error_str for keyword in ['invalid url', 'unsupported url', 'no video found']): 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" return (
"Make sure you're using a valid YouTube, Vimeo, or other supported platform URL.") "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 # YouTube premium content
if any(keyword in error_str for keyword in ['youtube premium', 'premium', 'members only']): if any(keyword in error_str for keyword in ["youtube premium", "premium", "members only"]):
return ("This content requires YouTube Premium or channel membership.\n" return (
"You need to be logged in with an account that has access to this content.") "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 # Copyright/DMCA
if any(keyword in error_str for keyword in ['copyright', 'dmca', 'blocked']): if any(keyword in error_str for keyword in ["copyright", "dmca", "blocked"]):
return ("This video is blocked due to copyright claims.\n" return "This video is blocked due to copyright claims.\n" "The content owner has restricted access to this video."
"The content owner has restricted access to this video.")
# Extraction errors (could be temporary) # Extraction errors (could be temporary)
if any(keyword in error_str for keyword in ['unable to extract', 'extraction failed']): 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" return (
"Please try again in a few minutes, or check if the video link is correct.") "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 # Generic fallback with the original error for debugging
return (f"Could not extract video information. Please check your link.\n" return f"Could not extract video information. Please check your link.\n" f"Technical details: {error_message}"
f"Technical details: {error_message}")
+8 -8
View File
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from src.core.ytsage_logging import logger from src.utils.ytsage_logger import logger
from src.utils.ytsage_constants import ( from src.utils.ytsage_constants import (
APP_BIN_DIR, APP_BIN_DIR,
ICON_PATH, ICON_PATH,
@@ -94,7 +94,7 @@ class YtdlpSetupDialog(QDialog):
# icon_path logic moved to src\utils\ytsage_constants.py # icon_path logic moved to src\utils\ytsage_constants.py
icon_path = ICON_PATH icon_path = ICON_PATH
if Path.exists(icon_path): if Path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path.as_posix())) self.setWindowIcon(QIcon(str(icon_path)))
self.init_ui() self.init_ui()
@@ -390,11 +390,11 @@ class YtdlpSetupDialog(QDialog):
self.setup_complete.emit(target_path) self.setup_complete.emit(target_path)
self.accept() self.accept()
except Exception as copy_error: 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 = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Setup Error") 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( error_dialog.setStyleSheet(
""" """
QMessageBox { QMessageBox {
@@ -448,11 +448,11 @@ class YtdlpSetupDialog(QDialog):
) )
error_dialog.exec() error_dialog.exec()
except Exception as e: 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 = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Error") 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( error_dialog.setStyleSheet(
""" """
QMessageBox { QMessageBox {
@@ -492,7 +492,7 @@ def check_ytdlp_binary() -> Optional[Path]:
os.chmod(exe_path, 0o755) os.chmod(exe_path, 0o755)
logger.info(f"Fixed permissions on yt-dlp at {exe_path}") logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
except Exception as e: 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 return exe_path
# If not found in app directory, check if yt-dlp is available in 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}") logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
return Path(yt_dlp_path) return Path(yt_dlp_path)
except Exception as e: 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 # We're only interested in our app-specific installation or system PATH
return None return None
+1 -4
View File
@@ -13,10 +13,7 @@ This package contains all dialog classes organized by functionality:
# Re-export all dialog classes for backward compatibility # 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_base import AboutDialog, LogWindow
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import ( from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
CustomOptionsDialog,
TimeRangeDialog,
)
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import ( from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
PlaylistSelectionDialog, PlaylistSelectionDialog,
@@ -3,6 +3,8 @@ Base dialogs for YTSage application.
Contains basic utility dialogs like LogWindow and AboutDialog. Contains basic utility dialogs like LogWindow and AboutDialog.
""" """
from datetime import datetime
from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@@ -155,7 +157,7 @@ class AboutDialog(QDialog):
layout.addWidget(title_label) layout.addWidget(title_label)
version_label = QLabel( version_label = QLabel(
f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.8.0b')}</span>" f"<span style='color: #cccccc; font-size: 13px; font-weight: normal;'>Version {getattr(self._parent, 'version', '4.8.3')}</span>"
) )
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version_label) layout.addWidget(version_label)
@@ -392,8 +394,6 @@ class AboutDialog(QDialog):
last_check = ytdlp_cache.get("last_check", 0) last_check = ytdlp_cache.get("last_check", 0)
cache_status = "" cache_status = ""
if last_check > 0: if last_check > 0:
from datetime import datetime
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M") cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
@@ -426,8 +426,6 @@ class AboutDialog(QDialog):
last_check = ffmpeg_cache.get("last_check", 0) last_check = ffmpeg_cache.get("last_check", 0)
cache_status = "" cache_status = ""
if last_check > 0 and ffmpeg_found: if last_check > 0 and ffmpeg_found:
from datetime import datetime
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M") cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px cache_status = f" <span style='color: #888; font-size: 10px;'>({cache_time})</span>" # Increased from 9px
@@ -8,7 +8,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast 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 ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
@@ -31,13 +31,6 @@ from PySide6.QtWidgets import (
from src.core.ytsage_yt_dlp import get_yt_dlp_path from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import YTDLP_DOCS_URL 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: if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import) from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
@@ -160,7 +153,7 @@ class CustomOptionsDialog(QDialog):
# Convert Path to string properly and validate # Convert Path to string properly and validate
cookie_path_str = str(self._parent.cookie_file_path) cookie_path_str = str(self._parent.cookie_file_path)
# Only set if it looks like a valid path (more than just a drive letter) # 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) self.cookie_path_input.setText(cookie_path_str)
path_layout.addWidget(self.cookie_path_input) path_layout.addWidget(self.cookie_path_input)
@@ -175,9 +168,7 @@ class CustomOptionsDialog(QDialog):
self.cookie_browser_group = QGroupBox("Browser Selection") self.cookie_browser_group = QGroupBox("Browser Selection")
browser_layout = QVBoxLayout(self.cookie_browser_group) browser_layout = QVBoxLayout(self.cookie_browser_group)
browser_help = QLabel( browser_help = QLabel("Select the browser to extract cookies from. Make sure the browser is closed before extraction.")
"Select the browser to extract cookies from. Make sure the browser is closed before extraction."
)
browser_help.setWordWrap(True) browser_help.setWordWrap(True)
browser_help.setStyleSheet("color: #999999; font-size: 11px;") browser_help.setStyleSheet("color: #999999; font-size: 11px;")
browser_layout.addWidget(browser_help) browser_layout.addWidget(browser_help)
@@ -186,16 +177,7 @@ class CustomOptionsDialog(QDialog):
browser_select_layout.addWidget(QLabel("Browser:")) browser_select_layout.addWidget(QLabel("Browser:"))
self.browser_combo = QComboBox() self.browser_combo = QComboBox()
self.browser_combo.addItems([ self.browser_combo.addItems(["chrome", "firefox", "safari", "edge", "opera", "brave", "chromium", "vivaldi"])
"chrome",
"firefox",
"safari",
"edge",
"opera",
"brave",
"chromium",
"vivaldi"
])
browser_select_layout.addWidget(self.browser_combo) browser_select_layout.addWidget(self.browser_combo)
browser_layout.addLayout(browser_select_layout) browser_layout.addLayout(browser_select_layout)
@@ -261,10 +243,7 @@ class CustomOptionsDialog(QDialog):
# Command input # Command input
self.command_input = QPlainTextEdit() self.command_input = QPlainTextEdit()
self.command_input.setPlaceholderText( self.command_input.setPlaceholderText("Enter yt-dlp arguments here...\n\n" "e.g. --extract-audio --audio-format mp3")
"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.setMinimumHeight(80) # Reduced further from 100
self.command_input.setStyleSheet( self.command_input.setStyleSheet(
""" """
@@ -469,7 +448,7 @@ class CustomOptionsDialog(QDialog):
if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option: if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option:
# Browser cookies are active # Browser cookies are active
self.cookie_browser_radio.setChecked(True) 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] browser = browser_parts[0]
profile = browser_parts[1] if len(browser_parts) > 1 else "" profile = browser_parts[1] if len(browser_parts) > 1 else ""
@@ -48,7 +48,7 @@ class FFmpegCheckDialog(QDialog):
# icon_path logic moved to src\utils\ytsage_constants.py # icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists(): if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix())) self.setWindowIcon(QIcon(str(ICON_PATH)))
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(15) layout.setSpacing(15)
@@ -3,11 +3,13 @@ Settings-related dialogs for YTSage application.
Contains dialogs for configuring download settings and auto-update preferences. Contains dialogs for configuring download settings and auto-update preferences.
""" """
import threading
import time import time
from datetime import datetime from datetime import datetime
import requests import requests
from PySide6.QtCore import Qt from packaging import version as version_parser
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
@@ -25,13 +27,13 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from src.core.ytsage_logging import logger
from src.core.ytsage_utils import ( from src.core.ytsage_utils import (
check_and_update_ytdlp_auto, check_and_update_ytdlp_auto,
get_auto_update_settings, get_auto_update_settings,
get_ytdlp_version, get_ytdlp_version,
update_auto_update_settings, update_auto_update_settings,
) )
from src.utils.ytsage_logger import logger
class DownloadSettingsDialog(QDialog): class DownloadSettingsDialog(QDialog):
@@ -164,7 +166,7 @@ class DownloadSettingsDialog(QDialog):
path_group_box = QGroupBox("Download Path") path_group_box = QGroupBox("Download Path")
path_layout = QVBoxLayout() 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.setWordWrap(True)
self.path_display.setStyleSheet( self.path_display.setStyleSheet(
"QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }" "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) layout.addWidget(button_box)
def browse_new_path(self) -> None: 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: if new_path:
self.current_path = new_path self.current_path = new_path
self.path_display.setText(self.current_path) self.path_display.setText(self.current_path)
@@ -329,8 +331,6 @@ class DownloadSettingsDialog(QDialog):
current_version = current_version.replace("_", ".") current_version = current_version.replace("_", ".")
latest_version = latest_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): if version_parser.parse(latest_version) > version_parser.parse(current_version):
msg_box = self._create_styled_message_box( msg_box = self._create_styled_message_box(
QMessageBox.Icon.Information, QMessageBox.Icon.Information,
@@ -349,7 +349,7 @@ class DownloadSettingsDialog(QDialog):
msg_box = self._create_styled_message_box( msg_box = self._create_styled_message_box(
QMessageBox.Icon.Warning, QMessageBox.Icon.Warning,
"Update Check", "Update Check",
f"Error checking for updates: {str(e)}", f"Error checking for updates: {e}",
) )
msg_box.exec() msg_box.exec()
@@ -381,7 +381,7 @@ class DownloadSettingsDialog(QDialog):
else: else:
QMessageBox.warning(self, "Error", "Failed to save auto-update settings.") QMessageBox.warning(self, "Error", "Failed to save auto-update settings.")
except Exception as e: 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 # Call the parent accept method to close the dialog
super().accept() super().accept()
@@ -582,7 +582,7 @@ class AutoUpdateSettingsDialog(QDialog):
self.on_enable_toggled(settings["enabled"]) self.on_enable_toggled(settings["enabled"])
except Exception as e: 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: def update_next_check_label(self) -> None:
"""Update the next check label based on current settings.""" """Update the next check label based on current settings."""
@@ -616,7 +616,7 @@ class AutoUpdateSettingsDialog(QDialog):
except Exception as e: except Exception as e:
self.next_check_label.setText("Next check: Error calculating") 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: def on_enable_toggled(self, enabled) -> None:
"""Handle enable/disable checkbox toggle.""" """Handle enable/disable checkbox toggle."""
@@ -646,16 +646,12 @@ class AutoUpdateSettingsDialog(QDialog):
result = check_and_update_ytdlp_auto() result = check_and_update_ytdlp_auto()
# Update UI in main thread # Update UI in main thread
from PySide6.QtCore import QTimer
QTimer.singleShot(0, lambda: self.manual_check_finished(result)) QTimer.singleShot(0, lambda: self.manual_check_finished(result))
except Exception as e: 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)) QTimer.singleShot(0, lambda: self.manual_check_finished(False))
# Run in separate thread to avoid blocking UI # Run in separate thread to avoid blocking UI
import threading
threading.Thread(target=check_in_thread, daemon=True).start() threading.Thread(target=check_in_thread, daemon=True).start()
def _create_styled_message_box(self, icon, title, text) -> QMessageBox: def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
@@ -738,6 +734,6 @@ class AutoUpdateSettingsDialog(QDialog):
) )
msg_box.exec() msg_box.exec()
except Exception as e: except Exception as e:
logger.error(f"Error saving auto-update settings: {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: {str(e)}") msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {e}")
msg_box.exec() msg_box.exec()
@@ -7,6 +7,7 @@ import os
import subprocess import subprocess
import sys import sys
import time import time
from importlib.metadata import PackageNotFoundError
from pathlib import Path from pathlib import Path
import requests import requests
@@ -14,14 +15,14 @@ from packaging import version
from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout 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_utils import get_ytdlp_version, load_config, save_config
from src.core.ytsage_yt_dlp import get_yt_dlp_path 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_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
from src.utils.ytsage_logger import logger
try: try:
from importlib.metadata import version as importlib_version
from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
from importlib.metadata import version as importlib_version
def get_version(package_name: str) -> str: def get_version(package_name: str) -> str:
return importlib_version(package_name) return importlib_version(package_name)
@@ -30,8 +31,10 @@ try:
except ImportError: except ImportError:
# Fallback for older Python versions # Fallback for older Python versions
import pkg_resources import pkg_resources
def get_version(package_name: str) -> str: def get_version(package_name: str) -> str:
return pkg_resources.get_distribution(package_name).version return pkg_resources.get_distribution(package_name).version
PackageNotFoundError = pkg_resources.DistributionNotFound PackageNotFoundError = pkg_resources.DistributionNotFound
try: try:
@@ -71,7 +74,7 @@ class VersionCheckThread(QThread):
else: else:
error_message = "yt-dlp not available." error_message = "yt-dlp not available."
self.finished.emit(current_version, latest_version, error_message) self.finished.emit(current_version, latest_version, error_message)
return return
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# Try fallback if timeout # Try fallback if timeout
if YT_DLP_AVAILABLE: 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." error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
except requests.RequestException as e: 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) self.update_status.emit(error_message)
success = False success = False
except Exception as e: except Exception as e:
error_message = f"❌ Update failed: {str(e)}" error_message = f"❌ Update failed: {e}"
self.update_status.emit(error_message) self.update_status.emit(error_message)
success = False success = False
@@ -207,7 +210,7 @@ class UpdateThread(QThread):
return False return False
except Exception as e: 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}") self.update_status.emit(f"❌ Unexpected error during update: {e}")
return False return False
@@ -553,10 +556,7 @@ class AutoUpdateThread(QThread):
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}") logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
self.update_finished.emit(False, f"Network error: {e}") self.update_finished.emit(False, f"Network error: {e}")
except Exception as e: except Exception as e:
logger.error( logger.exception(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
f"AutoUpdateThread: Error during auto-update check: {e}",
exc_info=True,
)
self.update_finished.emit(False, f"Update check error: {e}") self.update_finished.emit(False, f"Update check error: {e}")
except Exception as e: except Exception as e:
@@ -595,7 +595,7 @@ class AutoUpdateThread(QThread):
return self._update_via_pip() return self._update_via_pip()
except Exception as e: 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 return False
def _update_binary(self, yt_dlp_path: Path) -> bool: def _update_binary(self, yt_dlp_path: Path) -> bool:
@@ -629,7 +629,7 @@ class AutoUpdateThread(QThread):
return False return False
except Exception as e: 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 return False
def _update_via_pip(self) -> bool: def _update_via_pip(self) -> bool:
@@ -684,5 +684,5 @@ class AutoUpdateThread(QThread):
return True return True
except Exception as e: 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 return False
+21 -8
View File
@@ -1,7 +1,12 @@
from typing import TYPE_CHECKING, cast
from PySide6.QtCore import QObject, Qt, Signal from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget 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): class FormatSignals(QObject):
format_update = Signal(list) format_update = Signal(list)
@@ -9,8 +14,9 @@ class FormatSignals(QObject):
class FormatTableMixin: class FormatTableMixin:
def setup_format_table(self) -> QTableWidget: 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 # Format table with improved styling
self.format_table = QTableWidget() self.format_table = QTableWidget()
self.format_table.setColumnCount(8) self.format_table.setColumnCount(8)
@@ -124,6 +130,8 @@ class FormatTableMixin:
return self.format_table return self.format_table
def filter_formats(self) -> None: def filter_formats(self) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
if not hasattr(self, "all_formats"): if not hasattr(self, "all_formats"):
return return
@@ -165,6 +173,8 @@ class FormatTableMixin:
self.format_signals.format_update.emit(filtered_formats) self.format_signals.format_update.emit(filtered_formats)
def _update_format_table(self, formats) -> None: def _update_format_table(self, formats) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.format_table.setRowCount(0) self.format_table.setRowCount(0)
self.format_checkboxes.clear() self.format_checkboxes.clear()
@@ -331,22 +341,30 @@ class FormatTableMixin:
self.format_table.setItem(row, 7, notes_item) self.format_table.setItem(row, 7, notes_item)
def handle_checkbox_click(self, clicked_checkbox) -> None: def handle_checkbox_click(self, clicked_checkbox) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
for checkbox in self.format_checkboxes: for checkbox in self.format_checkboxes:
if checkbox != clicked_checkbox: if checkbox != clicked_checkbox:
checkbox.setChecked(False) checkbox.setChecked(False)
def get_selected_format(self): def get_selected_format(self):
self = cast("YTSageApp", self) # for autocompletion and type inference.
for checkbox in self.format_checkboxes: for checkbox in self.format_checkboxes:
if checkbox.isChecked(): if checkbox.isChecked():
return checkbox.format_id return checkbox.format_id
return None return None
def update_format_table(self, formats) -> None: def update_format_table(self, formats) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
self.all_formats = formats self.all_formats = formats
self.format_signals.format_update.emit(formats) self.format_signals.format_update.emit(formats)
def get_quality_label(self, format_info) -> str: def get_quality_label(self, format_info) -> str:
"""Determine quality label based on format information""" """Determine quality label based on format information"""
self = cast("YTSageApp", self) # for autocompletion and type inference.
if format_info.get("vcodec") == "none": if format_info.get("vcodec") == "none":
# Audio quality # Audio quality
abr = format_info.get("abr", 0) abr = format_info.get("abr", 0)
@@ -383,17 +401,12 @@ class FormatTableMixin:
def _get_format_notes(self, format_info) -> str: def _get_format_notes(self, format_info) -> str:
"""Generate helpful format notes based on format info.""" """Generate helpful format notes based on format info."""
self = cast("YTSageApp", self) # for autocompletion and type inference.
notes = [] notes = []
# Add storage indicator with more granular categories # Add storage indicator with more granular categories
file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0) 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 # Better file size categories
if file_size > 50 * 1024 * 1024: # Over 50MB if file_size > 50 * 1024 * 1024: # Over 50MB
+150 -147
View File
@@ -5,9 +5,10 @@ import webbrowser
from pathlib import Path from pathlib import Path
import markdown import markdown
import pyglet
import requests import requests
from packaging import version 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.QtGui import QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, 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_downloader import DownloadThread, SignalManager # Import downloader related classes
from src.core.ytsage_logging import logger 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_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_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions 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 from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
AboutDialog, 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_format_table import FormatTableMixin
from src.gui.ytsage_gui_video_info import VideoInfoMixin 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_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS
from src.utils.ytsage_logger import logger
try: try:
import yt_dlp import yt_dlp
from yt_dlp.utils import ExtractorError, DownloadError from yt_dlp.utils import DownloadError, ExtractorError
YT_DLP_AVAILABLE = True YT_DLP_AVAILABLE = True
except ImportError: except ImportError:
YT_DLP_AVAILABLE = False YT_DLP_AVAILABLE = False
try:
import pyglet
PYGLET_AVAILABLE = True
except ImportError:
PYGLET_AVAILABLE = False
class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
# Initialize logger for this class
self.logger = logger.bind(module="YTSageApp")
# Log startup warnings for missing dependencies # Log startup warnings for missing dependencies
if not YT_DLP_AVAILABLE: if not YT_DLP_AVAILABLE:
self.logger.warning("yt-dlp not available at startup, will be downloaded at runtime") 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")
# Check for FFmpeg before proceeding # Check for FFmpeg before proceeding
if not check_ffmpeg(): 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 if ytdlp_path == "yt-dlp": # Not found in app dir or PATH
self.show_ytdlp_setup_dialog() self.show_ytdlp_setup_dialog()
else: 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() self.check_for_updates()
# Check for auto-updates if enabled # Check for auto-updates if enabled
@@ -95,9 +83,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
load_saved_path(self) load_saved_path(self)
# Load custom icon # Load custom icon
if ICON_PATH.exists(): if ICON_PATH.exists():
self.setWindowIcon(QIcon(ICON_PATH.as_posix())) self.setWindowIcon(QIcon(str(ICON_PATH)))
else: 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.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
self.signals = SignalManager() self.signals = SignalManager()
self.download_paused = False self.download_paused = False
@@ -305,53 +293,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Initialize UI state based on current mode # Initialize UI state based on current mode
self.handle_mode_change() self.handle_mode_change()
# Initialize pyglet for sound notifications # Init_sound method is removed, serve no purpose.
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
def play_notification_sound(self) -> None: def play_notification_sound(self) -> None:
"""Play notification sound in a separate thread to avoid blocking the UI""" """Play notification sound asynchronously (non-blocking)."""
if not self.sound_enabled: try:
return # 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: # Play the sound using pyglet
try: # no need for the thread, as .play() is async
if PYGLET_AVAILABLE: sound = pyglet.media.load(str(SOUND_PATH), streaming=False)
# Play the sound using pyglet sound.play()
sound = pyglet.media.load(str(self.notification_sound_path)) logger.debug("Notification sound played")
sound.play() except Exception as e:
logger.exception(f"Error playing notification sound: {e}")
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
def init_ui(self) -> None: def init_ui(self) -> None:
self.setWindowTitle(f"YTSage v{self.version}") 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 # Initial extraction with basic options - suppress warnings here too
ydl_opts = { ydl_opts = {
"logger": logger,
"quiet": False, "quiet": False,
"no_warnings": True, # <-- Suppress warnings for initial check "no_warnings": True, # <-- Suppress warnings for initial check
"extract_flat": True, "extract_flat": True,
@@ -745,30 +704,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path: if self.cookie_file_path:
ydl_opts["cookiefile"] = str(self.cookie_file_path) ydl_opts["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option: elif self.browser_cookies_option:
ydl_opts["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0], ydl_opts["cookiesfrombrowser"] = (
self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None) 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: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try: try:
basic_info = ydl.extract_info(url, download=False) basic_info = ydl.extract_info(url, download=False)
if not basic_info: if not basic_info:
# This case usually means the URL is invalid or not found logger.error("Could not extract basic video information")
raise Exception("Invalid URL or video not found. Please check the link and try again.") self.signals.update_status.emit(
except (ExtractorError, DownloadError) as e: "Error: Could not extract basic video information. Please check your link."
# This is a yt-dlp specific error, use the original message )
user_friendly_error = parse_yt_dlp_error(str(e)) # Hide playlist UI on error
raise Exception(user_friendly_error) self.signals.playlist_info_label_visible.emit(False)
self.signals.playlist_select_btn_visible.emit(False)
return
except Exception as e: except Exception as e:
# This is our own exception or other unexpected error logger.exception(f"First extraction failed: {e}")
self.logger.error(f"First extraction failed: {str(e)}") self.signals.playlist_info_label_visible.emit(False)
self.logger.error(f"Exception type: {type(e)}") self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e)) 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") self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info")
# Configure options for detailed extraction (keep other options) # Configure options for detailed extraction (keep other options)
# Add no_warnings here as well, as this is where detailed info is fetched # Add no_warnings here as well, as this is where detailed info is fetched
ydl_opts_detail = { ydl_opts_detail = {
"logger": logger,
"extract_flat": False, "extract_flat": False,
"format": None, "format": None,
"writesubtitles": True, "writesubtitles": True,
@@ -785,8 +750,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path: if self.cookie_file_path:
ydl_opts_detail["cookiefile"] = str(self.cookie_file_path) ydl_opts_detail["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option: elif self.browser_cookies_option:
ydl_opts_detail["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0], ydl_opts_detail["cookiesfrombrowser"] = (
self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None) 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 # Use a separate options dict for the detailed extraction
with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail: 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 # Ensure there are entries before proceeding
if not self.playlist_entries: 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 # Extract detailed info for the FIRST video in the playlist
# This provides formats/subs for the UI, assuming consistency # This provides formats/subs for the UI, assuming consistency
first_video_url = self.playlist_entries[0].get("url") first_video_url = self.playlist_entries[0].get("url")
if not first_video_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: try:
# Use the ydl_detail instance with no_warnings # Use the ydl_detail instance with no_warnings
self.video_info = ydl_detail.extract_info(first_video_url, download=False) self.video_info = ydl_detail.extract_info(first_video_url, download=False)
except (ExtractorError, DownloadError) as first_video_error: except Exception as e:
# Use error parser for yt-dlp specific playlist video errors logger.exception(f"Failed to extract info for the first playlist video: {e}")
user_friendly_error = parse_yt_dlp_error(str(first_video_error)) self.signals.playlist_info_label_visible.emit(False)
raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}") self.signals.playlist_select_btn_visible.emit(False)
except Exception as first_video_error: user_friendly_error = parse_yt_dlp_error(str(e))
# Use error parser for other playlist video errors too self.signals.update_status.emit(user_friendly_error)
user_friendly_error = parse_yt_dlp_error(str(first_video_error)) return
raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}")
# Update playlist info label text (remains the same) # Update playlist info label text (remains the same)
playlist_text = ( playlist_text = (
f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos" 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 # Verify we have format information
if not self.video_info or "formats" not in self.video_info: 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'}") logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
raise Exception("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 (60%)... Processing formats") self.signals.update_status.emit("Analyzing (60%)... Processing formats")
self.all_formats = self.video_info["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 # Try to get thumbnail from playlist info first
# Fallback to video thumbnail if playlist thumbnail not found or not a playlist # 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") thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail")
self.download_thumbnail(thumbnail_url) self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled - use the stored VIDEO 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.video_button.setChecked(True)
self.audio_button.setChecked(False) self.audio_button.setChecked(False)
self.filter_formats() self.filter_formats()
self.signals.update_status.emit("Analysis complete!") 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: 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 # Use the error parser for other extraction errors too
user_friendly_error = parse_yt_dlp_error(str(e)) 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: 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}") self.signals.update_status.emit(f"Error: {e}")
# Ensure playlist UI is hidden on error too # Ensure playlist UI is hidden on error too
# update signal method from QMetaObject.invokeMethod to signals # update signal method from QMetaObject.invokeMethod to signals
@@ -944,7 +915,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.last_path = new_path self.last_path = new_path
save_path(self, self.last_path) # Save the updated path save_path(self, self.last_path) # Save the updated path
path_changed = True 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 # Update Speed Limit
new_limit_value = dialog.get_selected_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_value = new_limit_value
self.speed_limit_unit_index = new_unit_index self.speed_limit_unit_index = new_unit_index
limit_changed = True 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'}" 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: try:
self.download_thumbnail_file(url, path) self.download_thumbnail_file(url, path)
except Exception as e: 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 # Optionally inform the user, but don't stop the main download
# Create download thread with resolution in output template # Create download thread with resolution in output template
@@ -1116,7 +1087,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
int_value = int(value) int_value = int(value)
self.progress_bar.setValue(int_value) self.progress_bar.setValue(int_value)
except Exception as e: 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: def toggle_pause(self) -> None:
if self.current_download: 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 changelog = latest_release.get("body", "No changelog available.") # Get changelog body
self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog
except Exception as e: 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 def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter
msg = QDialog(self) msg = QDialog(self)
@@ -1161,7 +1132,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Fallback to icon file # Fallback to icon file
# icon_path logic moved to src\utils\ytsage_constants.py # icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists(): if ICON_PATH.exists():
msg.setWindowIcon(QIcon(ICON_PATH.as_posix())) msg.setWindowIcon(QIcon(str(ICON_PATH)))
except Exception: except Exception:
pass pass
@@ -1224,7 +1195,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
) )
changelog_text.setHtml(html_changelog) changelog_text.setHtml(html_changelog)
except Exception as e: 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.setPlainText(changelog) # Fallback to plain text
changelog_text.setStyleSheet( changelog_text.setStyleSheet(
@@ -1339,14 +1310,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try: try:
# Check if auto-update should be performed # Check if auto-update should be performed
if should_check_for_auto_update(): 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 # Perform the auto-update in a non-blocking way
# We don't want to block the UI startup for this # 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 QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup
except Exception as e: 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: def _perform_auto_update(self) -> None:
"""Actually perform the auto-update check and update if needed in a background thread.""" """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.update_finished.connect(self._on_auto_update_finished)
self.auto_update_thread.start() self.auto_update_thread.start()
except Exception as e: 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: def _on_auto_update_finished(self, success, message) -> None:
"""Handle auto-update completion.""" """Handle auto-update completion."""
if success: if success:
self.logger.info(f"Auto-update completed successfully: {message}") logger.info(f"Auto-update completed successfully: {message}")
else: 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 # Clean up the thread reference and ensure it's properly finished
if hasattr(self, "auto_update_thread"): if hasattr(self, "auto_update_thread"):
@@ -1382,26 +1351,26 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try: try:
# Stop the auto-update thread if it's running # Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning(): 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() self.auto_update_thread.quit()
if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown 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.terminate()
self.auto_update_thread.wait(1000) # Wait for termination self.auto_update_thread.wait(1000) # Wait for termination
# Cancel any running downloads # Cancel any running downloads
if self.current_download and self.current_download.isRunning(): if self.current_download and self.current_download.isRunning():
self.logger.info("Canceling running download...") logger.info("Canceling running download...")
self.current_download.cancel() self.current_download.cancel()
if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown 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.terminate()
self.current_download.wait(1000) # Wait for termination self.current_download.wait(1000) # Wait for termination
self.logger.info("Application closing...") logger.info("Application closing...")
event.accept() event.accept()
except Exception as e: 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 event.accept() # Accept the close event anyway
def show_custom_options(self) -> None: def show_custom_options(self) -> None:
@@ -1417,7 +1386,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if cookie_path: if cookie_path:
self.cookie_file_path = 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( QMessageBox.information(
self, self,
"Cookie File Selected", "Cookie File Selected",
@@ -1425,7 +1394,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
) )
elif browser_cookies: elif browser_cookies:
self.browser_cookies_option = 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( QMessageBox.information(
self, self,
"Browser Cookies Selected", "Browser Cookies Selected",
@@ -1499,32 +1468,32 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# --- Add Toggle Methods Here --- # --- Add Toggle Methods Here ---
def toggle_save_thumbnail(self, state) -> None: 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.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: 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.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: 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.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 --- # --- End Toggle Methods ---
def open_playlist_selection_dialog(self) -> None: def open_playlist_selection_dialog(self) -> None:
if not self.is_playlist or not self.playlist_entries: 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 return
dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self) dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
if dialog.exec(): if dialog.exec():
self.selected_playlist_items = dialog.get_selected_items_string() 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) # Update button text (this call is safe as it happens in the main thread after dialog closes)
if self.selected_playlist_items is None: if self.selected_playlist_items is None:
@@ -1616,7 +1585,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if cookie_path: if cookie_path:
self.cookie_file_path = cookie_path self.cookie_file_path = cookie_path
self.browser_cookies_option = None # Clear browser cookies if file is used 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( QMessageBox.information(
self, self,
"Cookie File Selected", "Cookie File Selected",
@@ -1625,7 +1594,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
elif browser_cookies: elif browser_cookies:
self.browser_cookies_option = browser_cookies self.browser_cookies_option = browser_cookies
self.cookie_file_path = None # Clear file cookies if browser is used 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( QMessageBox.information(
self, self,
"Browser Cookies Selected", "Browser Cookies Selected",
@@ -1717,7 +1686,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try: try:
yt_dlp_path = get_yt_dlp_path() yt_dlp_path = get_yt_dlp_path()
if not 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") 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) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SUBPROCESS_CREATIONFLAGS)
if result.returncode != 0: 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()] json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
if not json_lines: 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 try:
first_info = json.loads(json_lines[0]) 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") self.signals.update_status.emit("Analyzing (60%)... Processing data")
@@ -1770,7 +1756,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
continue continue
if not self.playlist_entries: 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 # Use first video for format information
self.video_info = self.playlist_entries[0] self.video_info = self.playlist_entries[0]
@@ -1806,7 +1796,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Verify we have format information # Verify we have format information
if not self.video_info or "formats" not in self.video_info: 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.signals.update_status.emit("Analyzing (75%)... Processing formats")
self.all_formats = self.video_info["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!") self.signals.update_status.emit("Analysis complete!")
except subprocess.TimeoutExpired: 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: 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: 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)
+32 -18
View File
@@ -2,22 +2,28 @@ import re
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast
import requests import requests
from PIL import Image from PIL import Image
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap 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 from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
SponsorBlockCategoryDialog, SponsorBlockCategoryDialog,
SubtitleSelectionDialog, SubtitleSelectionDialog,
) )
from src.utils.ytsage_logger import logger
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp
class VideoInfoMixin: class VideoInfoMixin:
def setup_video_info_section(self) -> QHBoxLayout: 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 # Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout() media_info_layout = QHBoxLayout()
media_info_layout.setSpacing(15) media_info_layout.setSpacing(15)
@@ -184,6 +190,8 @@ class VideoInfoMixin:
return media_info_layout return media_info_layout
def setup_playlist_info_section(self) -> QLabel: 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 = QLabel()
self.playlist_info_label.setVisible(False) self.playlist_info_label.setVisible(False)
self.playlist_info_label.setStyleSheet( self.playlist_info_label.setStyleSheet(
@@ -205,6 +213,8 @@ class VideoInfoMixin:
return self.playlist_info_label return self.playlist_info_label
def update_video_info(self, info) -> None: 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: if hasattr(self, "is_playlist") and self.is_playlist:
# Playlist Mode: Show playlist title and video count # Playlist Mode: Show playlist title and video count
self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist")) 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}") self.duration_label.setText(f"Duration: {duration_str}")
def open_subtitle_dialog(self) -> None: 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"): if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
logger.warning("Subtitle info not loaded yet.") logger.warning("Subtitle info not loaded yet.")
return return
@@ -274,16 +286,8 @@ class VideoInfoMixin:
self, # Parent for the dialog self, # Parent for the dialog
) )
# Access the main application window (parent of the mixin's widget) # removed extra logic for mapping to main_windows
# to find the merge checkbox merge_checkbox = getattr(self, "merge_subs_checkbox", None)
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)
if dialog.exec(): # If user clicks OK if dialog.exec(): # If user clicks OK
self.selected_subtitles = dialog.get_selected_subtitles() self.selected_subtitles = dialog.get_selected_subtitles()
@@ -296,7 +300,7 @@ class VideoInfoMixin:
# Enable/disable the merge checkbox in the parent window # Enable/disable the merge checkbox in the parent window
if merge_checkbox: if merge_checkbox:
# Only enable merge checkbox if we're not in Audio Only mode # 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 # In audio-only mode, we still allow subtitle selection but not merging
should_enable = count > 0 and not is_audio_only should_enable = count > 0 and not is_audio_only
merge_checkbox.setEnabled(should_enable) merge_checkbox.setEnabled(should_enable)
@@ -310,6 +314,8 @@ class VideoInfoMixin:
def open_sponsorblock_dialog(self) -> None: def open_sponsorblock_dialog(self) -> None:
"""Open the SponsorBlock category selection dialog.""" """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) # Initialize selected categories if not exists or empty (first time opening)
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories: if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
# Use None to let the dialog set its own defaults # Use None to let the dialog set its own defaults
@@ -326,6 +332,8 @@ class VideoInfoMixin:
def _update_sponsorblock_display(self) -> None: def _update_sponsorblock_display(self) -> None:
"""Update the SponsorBlock button and label to reflect current selection.""" """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"): if not hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = [] self.selected_sponsorblock_categories = []
@@ -347,6 +355,8 @@ class VideoInfoMixin:
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn) self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
def download_thumbnail(self, url) -> None: def download_thumbnail(self, url) -> None:
self = cast("YTSageApp", self) # for autocompletion and type inference.
try: try:
# Store both thumbnail URL and video URL # Store both thumbnail URL and video URL
self.thumbnail_url = url self.thumbnail_url = url
@@ -364,7 +374,10 @@ class VideoInfoMixin:
pixmap.loadFromData(img_byte_arr.getvalue()) pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap) self.thumbnail_label.setPixmap(pixmap)
except Exception as e: 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: def download_thumbnail_file(self, video_url, path) -> bool:
if not self.save_thumbnail: if not self.save_thumbnail:
@@ -377,6 +390,7 @@ class VideoInfoMixin:
logger.debug(f"Attempting to save thumbnail for URL: {video_url}") logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = { ydl_opts = {
"logger": logger,
"quiet": True, "quiet": True,
"skip_download": True, "skip_download": True,
"force_generic_extractor": False, "force_generic_extractor": False,
@@ -389,7 +403,7 @@ class VideoInfoMixin:
thumbnails = info.get("thumbnails", []) thumbnails = info.get("thumbnails", [])
if not thumbnails: if not thumbnails:
raise ValueError("No thumbnails available") logger.info("No thumbnails available")
thumbnail_url = max( thumbnail_url = max(
thumbnails, thumbnails,
@@ -397,7 +411,7 @@ class VideoInfoMixin:
).get("url") ).get("url")
if not thumbnail_url: if not thumbnail_url:
raise ValueError("Failed to extract thumbnail URL") logger.info("Failed to extract thumbnail URL")
# Download using requests # Download using requests
response = requests.get(thumbnail_url) response = requests.get(thumbnail_url)
@@ -418,8 +432,8 @@ class VideoInfoMixin:
return True return True
except Exception as e: except Exception as e:
error_msg = f"❌ Thumbnail error: {str(e)}" error_msg = f"❌ Thumbnail error: {e}"
logger.error(f"Thumbnail Save Error: {str(e)}") logger.exception(f"Thumbnail Save Error: {e}")
self.signals.update_status.emit(error_msg) self.signals.update_status.emit(error_msg)
return False return False
+202
View File
@@ -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.")
+17 -14
View File
@@ -59,14 +59,18 @@ SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
IS_FROZEN = getattr(sys, "frozen", False)
USER_HOME_DIR: Path = Path.home() USER_HOME_DIR: Path = Path.home()
# OS Specific Constants # OS Specific Constants
if OS_NAME == "Windows": if OS_NAME == "Windows":
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
# APP_PATH will be from system environment path or fallback to Path.home() if IS_FROZEN:
APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage" 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -75,16 +79,16 @@ if OS_NAME == "Windows":
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" 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" 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 SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW
elif OS_NAME == "Darwin": # macOS elif OS_NAME == "Darwin": # macOS
_mac_version = platform.mac_ver()[0] _mac_version = platform.mac_ver()[0]
OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS" 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -93,16 +97,16 @@ elif OS_NAME == "Darwin": # macOS
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_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" 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 SUBPROCESS_CREATIONFLAGS: int = 0
else: # Linux and other UNIX-like else: # Linux and other UNIX-like
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}" 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_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data" APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs" APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -111,11 +115,10 @@ else: # Linux and other UNIX-like
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" 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" 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 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 download links
FFMPEG_7Z_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z" 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 __name__ == "__main__":
# If this file is run directly, print directory information; if imported, create the necessary directories for the application. # 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 = { info = {
"OS_NAME": OS_NAME, "OS_NAME": OS_NAME,
"OS_FULL_NAME": OS_FULL_NAME, "OS_FULL_NAME": OS_FULL_NAME,
@@ -135,7 +139,6 @@ if __name__ == "__main__":
"APP_CONFIG_FILE": str(APP_CONFIG_FILE), "APP_CONFIG_FILE": str(APP_CONFIG_FILE),
"YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL, "YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL,
"YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH, "YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH,
"YTDLP_DOCS_URL": YTDLP_DOCS_URL,
"SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS, "SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS,
} }
for key, value in info.items(): for key, value in info.items():
+56
View File
@@ -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"]