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:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user