diff --git a/.github/CI_CD_README.md b/.github/CI_CD_README.md
index 0373f80..cfcea69 100644
--- a/.github/CI_CD_README.md
+++ b/.github/CI_CD_README.md
@@ -126,14 +126,14 @@ To add new platform packages:
```bash
# For a new release
-git tag v4.8.0
-git push origin v4.8.0
+git tag v4.8.1
+git push origin v4.8.1
# For a patch release
git tag v4.8.1
git push origin v4.8.1
# To delete a tag (if needed)
-git tag -d v4.8.0
-git push origin :refs/tags/v4.8.0
+git tag -d v4.8.1
+git push origin :refs/tags/v4.8.1
```
diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml
index fdc4d8a..f7416a4 100644
--- a/.github/workflows/build-linux.yml
+++ b/.github/workflows/build-linux.yml
@@ -122,7 +122,7 @@ jobs:
],
include_files=[
("src", "src"),
- ("assets", "assets"),
+ ("assets", "lib/assets"),
("ytsage.desktop", "share/applications/ytsage.desktop"),
("assets/branding/icons/icon.png", "share/pixmaps/ytsage.png"),
],
@@ -185,8 +185,8 @@ jobs:
# Remove screenshots to reduce size before packaging
build_dir=$(ls -d build/exe.* 2>/dev/null | head -n1 || true)
- if [ -n "$build_dir" ] && [ -d "$build_dir/assets/branding/screenshots" ]; then
- rm -rf "$build_dir/assets/branding/screenshots"
+ if [ -n "$build_dir" ] && [ -d "$build_dir/lib/assets/branding/screenshots" ]; then
+ rm -rf "$build_dir/lib/assets/branding/screenshots"
echo "Removed screenshots folder from $build_dir"
fi
diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml
index abb9881..52a95c4 100644
--- a/.github/workflows/build-macos.yml
+++ b/.github/workflows/build-macos.yml
@@ -123,7 +123,7 @@ jobs:
],
include_files=[
("src", "src"),
- ("assets", "assets"),
+ ("assets", "lib/assets"),
],
)
@@ -171,8 +171,8 @@ jobs:
if [ -z "$app_path" ]; then
app_path=$(ls -d dist/*.app build/dist/*.app build/*.app 2>/dev/null | head -n1 || true)
fi
- if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then
- rm -rf "$app_path/Contents/Resources/assets/branding/screenshots"
+ if [ -n "$app_path" ] && [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then
+ rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots"
echo "Removed screenshots folder from .app bundle at $app_path"
fi
echo "Post-bdist_mac directory listing:"
@@ -207,8 +207,8 @@ jobs:
app_base="$(basename "$app_path")"
app_parent="$(dirname "$app_path")"
# Ensure screenshots folder is not shipped
- if [ -d "$app_path/Contents/Resources/assets/branding/screenshots" ]; then
- rm -rf "$app_path/Contents/Resources/assets/branding/screenshots"
+ if [ -d "$app_path/Contents/Resources/lib/assets/branding/screenshots" ]; then
+ rm -rf "$app_path/Contents/Resources/lib/assets/branding/screenshots"
echo "Removed screenshots folder from .app bundle at $app_path"
fi
(cd "$app_parent" && zip -r "${GITHUB_WORKSPACE}/artifacts/YTSage-v${version}-${ARCH_SUFFIX}.app.zip" "$app_base")
diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
index 64df03a..057648a 100644
--- a/.github/workflows/build-windows.yml
+++ b/.github/workflows/build-windows.yml
@@ -62,6 +62,79 @@ jobs:
echo "VERSION=$version" >> $env:GITHUB_ENV
Write-Host "Prepared build variables for version: $version"
+ - name: Create cx_Freeze setup script
+ shell: powershell
+ run: |
+ # Create setup script for cx_Freeze
+ New-Item -Path "setup_cxfreeze.py" -ItemType File -Force
+ Add-Content -Path "setup_cxfreeze.py" -Value "import os"
+ Add-Content -Path "setup_cxfreeze.py" -Value "from cx_Freeze import setup, Executable"
+ Add-Content -Path "setup_cxfreeze.py" -Value ""
+ Add-Content -Path "setup_cxfreeze.py" -Value 'version = os.environ.get("VERSION", "0.0.0")'
+ Add-Content -Path "setup_cxfreeze.py" -Value ""
+ Add-Content -Path "setup_cxfreeze.py" -Value "build_exe_options = dict("
+ Add-Content -Path "setup_cxfreeze.py" -Value " optimize=2,"
+ Add-Content -Path "setup_cxfreeze.py" -Value " packages=["
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtCore",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtGui",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWidgets",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "requests",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "packaging",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "markdown",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "pyglet",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "loguru",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "setuptools",'
+ Add-Content -Path "setup_cxfreeze.py" -Value " ],"
+ Add-Content -Path "setup_cxfreeze.py" -Value " excludes=["
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtBluetooth",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtNetwork",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtOpenGL",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtPrintSupport",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSvg",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtTest",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtXml",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtSql",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtHelp",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtMultimedia",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQml",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtQuick",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PySide6.QtWebEngineCore",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageDraw",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "PIL.ImageFont",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "numpy",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "scipy",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "wx",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "pandas",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "tkinter",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "yt_dlp",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "unittest",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "test",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' "tests",'
+ Add-Content -Path "setup_cxfreeze.py" -Value " ],"
+ Add-Content -Path "setup_cxfreeze.py" -Value " include_files=["
+ Add-Content -Path "setup_cxfreeze.py" -Value ' ("src", "src"),'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' ("assets", "lib/assets"),'
+ Add-Content -Path "setup_cxfreeze.py" -Value " ],"
+ Add-Content -Path "setup_cxfreeze.py" -Value ")"
+ Add-Content -Path "setup_cxfreeze.py" -Value ""
+ Add-Content -Path "setup_cxfreeze.py" -Value "executables = ["
+ Add-Content -Path "setup_cxfreeze.py" -Value " Executable("
+ Add-Content -Path "setup_cxfreeze.py" -Value ' script="main.py",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' target_name=f"YTSage-v{version}.exe",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' base="Win32GUI",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' icon="assets/branding/icons/YTSage.ico",'
+ Add-Content -Path "setup_cxfreeze.py" -Value " )"
+ Add-Content -Path "setup_cxfreeze.py" -Value "]"
+ Add-Content -Path "setup_cxfreeze.py" -Value ""
+ Add-Content -Path "setup_cxfreeze.py" -Value "setup("
+ Add-Content -Path "setup_cxfreeze.py" -Value ' name="YTSage",'
+ Add-Content -Path "setup_cxfreeze.py" -Value " version=version,"
+ Add-Content -Path "setup_cxfreeze.py" -Value ' description="YTSage",'
+ Add-Content -Path "setup_cxfreeze.py" -Value ' options={"build_exe": build_exe_options},'
+ Add-Content -Path "setup_cxfreeze.py" -Value " executables=executables,"
+ Add-Content -Path "setup_cxfreeze.py" -Value ")"
+
- name: Build Standard Version (ZIP)
shell: powershell
run: |
@@ -74,21 +147,12 @@ jobs:
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force }
if (Test-Path "dist") { Remove-Item "dist" -Recurse -Force }
- # Build executable using cx_Freeze CLI (use python -m to ensure proper base handling)
- python -m cx_Freeze main.py `
- --target-dir "dist\YTSage" `
- --base-name Win32GUI `
- --icon "assets\branding\icons\YTSage.ico" `
- --target-name "YTSage-v$version.exe" `
- --optimize 2 `
- --packages "PySide6.QtCore,PySide6.QtGui,PySide6.QtWidgets,requests,PIL,packaging,markdown,pyglet,loguru,setuptools" `
- --excludes "PySide6.QtBluetooth,PySide6.QtNetwork,PySide6.QtOpenGL,PySide6.QtPrintSupport,PySide6.QtSvg,PySide6.QtTest,PySide6.QtXml,PySide6.QtSql,PySide6.QtHelp,PySide6.QtMultimedia,PySide6.QtQml,PySide6.QtQuick,PySide6.QtWebEngineCore,PIL.ImageDraw,PIL.ImageFont,numpy,scipy,wx,pandas,tkinter,yt_dlp,unittest,test,tests" `
- --include-files "src" `
- --include-files "assets"
+ # Build executable using setup script
+ python setup_cxfreeze.py build_exe --build-exe "dist\YTSage"
# Remove screenshots folder to reduce build size
- if (Test-Path "dist\YTSage\assets\branding\screenshots") {
- Remove-Item "dist\YTSage\assets\branding\screenshots" -Recurse -Force
+ if (Test-Path "dist\YTSage\lib\assets\branding\screenshots") {
+ Remove-Item "dist\YTSage\lib\assets\branding\screenshots" -Recurse -Force
Write-Host "Removed screenshots folder from standard build"
}
@@ -153,21 +217,15 @@ jobs:
if (Test-Path "build\exe.*") { Remove-Item "build\exe.*" -Recurse -Force }
if (Test-Path "build\bdist.*") { Remove-Item "build\bdist.*" -Recurse -Force }
- # Build executable with FFmpeg using cx_Freeze CLI (use python -m to ensure proper base handling)
- python -m cx_Freeze main.py `
- --target-dir "dist\YTSage-FFmpeg" `
- --base-name Win32GUI `
- --icon "assets\branding\icons\YTSage.ico" `
- --target-name "YTSage-v$version-ffmpeg.exe" `
- --optimize 2 `
- --packages "PySide6.QtCore,PySide6.QtGui,PySide6.QtWidgets,requests,PIL,packaging,markdown,pyglet,loguru,setuptools" `
- --excludes "PySide6.QtBluetooth,PySide6.QtNetwork,PySide6.QtOpenGL,PySide6.QtPrintSupport,PySide6.QtSvg,PySide6.QtTest,PySide6.QtXml,PySide6.QtSql,PySide6.QtHelp,PySide6.QtMultimedia,PySide6.QtQml,PySide6.QtQuick,PySide6.QtWebEngineCore,PIL.ImageDraw,PIL.ImageFont,numpy,scipy,wx,pandas,tkinter,yt_dlp,unittest,test,tests" `
- --include-files "src" `
- --include-files "assets"
+ # Create modified setup script for FFmpeg version
+ (Get-Content "setup_cxfreeze.py") -replace 'YTSage-v\{version\}\.exe', 'YTSage-v{version}-ffmpeg.exe' | Set-Content "setup_cxfreeze_ffmpeg.py"
+
+ # Build executable with FFmpeg using setup script
+ python setup_cxfreeze_ffmpeg.py build_exe --build-exe "dist\YTSage-FFmpeg"
# Remove screenshots folder to reduce build size
- if (Test-Path "dist\YTSage-FFmpeg\assets\branding\screenshots") {
- Remove-Item "dist\YTSage-FFmpeg\assets\branding\screenshots" -Recurse -Force
+ if (Test-Path "dist\YTSage-FFmpeg\lib\assets\branding\screenshots") {
+ Remove-Item "dist\YTSage-FFmpeg\lib\assets\branding\screenshots" -Recurse -Force
Write-Host "Removed screenshots folder from FFmpeg build"
}
diff --git a/main.py b/main.py
index cfbb542..e8e8eab 100644
--- a/main.py
+++ b/main.py
@@ -2,11 +2,8 @@ import sys
from PySide6.QtWidgets import QApplication, QMessageBox
-from src.core.ytsage_logging import logger
-from src.core.ytsage_yt_dlp import ( # Import the new yt-dlp setup functions
- check_ytdlp_binary,
- setup_ytdlp,
-)
+from src.utils.ytsage_logger import logger
+from src.core.ytsage_yt_dlp import check_ytdlp_binary, setup_ytdlp # Import the new yt-dlp setup functions
from src.gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
diff --git a/src/__init__.py b/src/__init__.py
index d9e962d..dc97c83 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -4,5 +4,5 @@ YTSage - YouTube Video Downloader
A modern, user-friendly YouTube video downloader built with PySide6.
"""
-__version__ = "4.8.0b"
+__version__ = "4.8.3"
__author__ = "oop7"
diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py
index 42369a6..e123ac5 100644
--- a/src/core/ytsage_downloader.py
+++ b/src/core/ytsage_downloader.py
@@ -7,9 +7,9 @@ from pathlib import Path
from PySide6.QtCore import QObject, QThread, Signal
-from src.core.ytsage_logging import logger
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
+from src.utils.ytsage_logger import logger
try:
import yt_dlp # Keep yt_dlp import here - only downloader uses it.
@@ -38,6 +38,7 @@ class DownloadThread(QThread):
error_signal = Signal(str)
file_exists_signal = Signal(str) # New signal for file existence
update_details = Signal(str) # New signal for filename, speed, ETA
+ update_details = Signal(str) # New signal for filename, speed, ETA
def __init__(
self,
@@ -97,9 +98,10 @@ class DownloadThread(QThread):
try:
file_path.unlink(missing_ok=True)
except Exception as e:
- logger.error(f"Error deleting {file_path.name}: {str(e)}")
+ logger.exception(f"Error deleting {file_path.name}: {e}")
except Exception as e:
- self.error_signal.emit(f"Error cleaning partial files: {str(e)}")
+ logger.exception(f"Error cleaning partial files: {e}")
+ self.error_signal.emit(f"Error cleaning partial files: {e}")
def cleanup_subtitle_files(self) -> None:
"""Delete subtitle files after they have been merged into the video file"""
@@ -111,7 +113,7 @@ class DownloadThread(QThread):
logger.debug(f"Deleted subtitle file: {path.name}")
return True
except Exception as e:
- logger.error(f"Error deleting subtitle file {path}: {e}")
+ logger.exception(f"Error deleting subtitle file {path}: {e}")
return False
try:
@@ -130,7 +132,7 @@ class DownloadThread(QThread):
else:
logger.debug(f"Deleted {deleted_count[1]} of {len(new_subtitle_files)} new subtitle files")
except Exception as e:
- logger.error(f"Error cleaning subtitle files: {str(e)}")
+ logger.exception(f"Error cleaning subtitle files: {e}")
def check_file_exists(self) -> bool | None:
"""Check if the file already exists before downloading"""
@@ -138,18 +140,21 @@ class DownloadThread(QThread):
logger.debug("Starting file existence check")
# Use yt-dlp to get the filename without downloading, suppressing warnings
ydl_opts_check = {
+ "logger": logger, # passed app logger
"quiet": True,
"skip_download": True,
"no_warnings": True, # <-- Suppress warnings during check
"ignoreerrors": True, # Also ignore other potential errors during this check
- "outtmpl": {"default": f"{self.path.as_posix()}/%(title)s.%(ext)s"},
+ "outtmpl": {"default": str(self.path / "%(title)s.%(ext)s")},
"format": (self.format_id if self.format_id else "best"), # Use selected format or best
}
if self.cookie_file:
ydl_opts_check["cookiefile"] = str(self.cookie_file)
elif self.browser_cookies:
- ydl_opts_check["cookiesfrombrowser"] = (self.browser_cookies.split(':')[0],
- self.browser_cookies.split(':')[1] if ':' in self.browser_cookies else None)
+ ydl_opts_check["cookiesfrombrowser"] = (
+ self.browser_cookies.split(":")[0],
+ self.browser_cookies.split(":")[1] if ":" in self.browser_cookies else None,
+ )
if YT_DLP_AVAILABLE:
with yt_dlp.YoutubeDL(ydl_opts_check) as ydl:
@@ -178,10 +183,7 @@ class DownloadThread(QThread):
return False # Proceed with download attempt
except Exception as e:
- logger.debug(f"Error checking file existence: {str(e)}")
- import traceback
-
- traceback.print_exc()
+ logger.exception(f"Error checking file existence: {e}")
return None
def _build_yt_dlp_command(self) -> list:
@@ -201,6 +203,7 @@ class DownloadThread(QThread):
try:
if YT_DLP_AVAILABLE:
ydl_opts = {
+ "logger": logger,
"quiet": True,
"no_warnings": True,
"skip_download": True,
@@ -214,7 +217,7 @@ class DownloadThread(QThread):
logger.debug(f"Detected audio-only format for ID: {clean_format_id}")
break
except Exception as e:
- logger.debug(f"Error checking if format is audio-only: {e}")
+ logger.exception(f"Error checking if format is audio-only: {e}")
# For audio-only formats, don't try to merge with video
if is_audio_format:
@@ -229,12 +232,9 @@ class DownloadThread(QThread):
try:
format_ext = None
logger.debug(f"Getting format information for format ID: {self.format_id} (using: {clean_format_id})")
+
if YT_DLP_AVAILABLE:
- ydl_opts = {
- "quiet": True,
- "no_warnings": True,
- "skip_download": True,
- }
+ ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True, "logger": logger}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(self.url, download=False) or {}
# Look for the clean format ID first
@@ -254,7 +254,7 @@ class DownloadThread(QThread):
# Ensure output matches the selected format - only for video formats
cmd.extend(["--merge-output-format", format_ext])
except Exception as e:
- logger.debug(f"Error detecting format extension: {e}")
+ logger.exception(f"Error detecting format extension: {e}")
# If we can't determine the format, don't specify merge-output-format
pass
else:
@@ -272,7 +272,7 @@ class DownloadThread(QThread):
else:
output_template = f"{base_path}/%(title)s_%(resolution)s.%(ext)s"
- cmd.extend(["-o", output_template])
+ cmd.extend(["-o", str(output_template)])
# Add common options
cmd.append("--force-overwrites")
@@ -295,7 +295,7 @@ class DownloadThread(QThread):
lang_code = sub_selection.split(" - ")[0]
lang_codes.append(lang_code)
except Exception as e:
- logger.warning(f"Could not parse subtitle selection '{sub_selection}': {e}")
+ logger.exception(f"Could not parse subtitle selection '{sub_selection}': {e}")
if lang_codes:
cmd.extend(["--sub-langs", ",".join(lang_codes)])
@@ -366,7 +366,7 @@ class DownloadThread(QThread):
self.initial_subtitle_files.add(file)
logger.debug(f"Found {len(self.initial_subtitle_files)} existing subtitle files before download")
except Exception as e:
- logger.warning(f"Error scanning for initial subtitle files: {e}")
+ logger.exception(f"Error scanning for initial subtitle files: {e}")
if self.use_direct_command:
# Use direct CLI command instead of Python API
@@ -377,10 +377,8 @@ class DownloadThread(QThread):
except Exception as e:
# Catch errors during setup
- self.error_signal.emit(f"Critical error in download thread: {str(e)}")
- import traceback
-
- traceback.print_exc()
+ logger.critical(f"Critical error in download thread: {e}", exc_info=True)
+ self.error_signal.emit(f"Critical error in download thread: {e}")
def _run_direct_command(self) -> None:
"""Run yt-dlp as a direct command line process instead of using Python API."""
@@ -460,7 +458,8 @@ class DownloadThread(QThread):
self.cleanup_partial_files()
except Exception as e:
- self.error_signal.emit(f"Error in direct command: {str(e)}")
+ logger.exception(f"Error in direct command: {e}")
+ self.error_signal.emit(f"Error in direct command: {e}")
self.cleanup_partial_files()
def _parse_output_line(self, line) -> None:
@@ -513,7 +512,7 @@ class DownloadThread(QThread):
else:
self.status_signal.emit(f"⏬ Downloading...")
except Exception as e:
- logger.error(f"Error extracting filename from line '{line}': {e}")
+ logger.exception(f"Error extracting filename from line '{line}': {e}")
self.status_signal.emit("⚡ Downloading...") # Fallback status
return # Don't process this line further for speed/ETA
@@ -535,16 +534,16 @@ class DownloadThread(QThread):
)
if subtitle_match:
subtitle_file = subtitle_match.group(1).strip()
-
+
# Clean up the path - remove any duplicated directory paths
# Sometimes yt-dlp output contains malformed paths like "dir: dir/file"
- if ":" in subtitle_file and os.name == 'nt': # Windows paths
+ if ":" in subtitle_file and os.name == "nt": # Windows paths
# Look for pattern like "C:\path: C:\path\file" and extract the latter
colon_parts = subtitle_file.split(": ")
if len(colon_parts) > 1:
# Take the last part which should be the actual file path
subtitle_file = colon_parts[-1].strip()
-
+
# Show subtitle download message
self.status_signal.emit(f"⏬ Downloading subtitle...")
# Store the subtitle file path for later deletion if merging is enabled
@@ -607,7 +606,7 @@ class DownloadThread(QThread):
self.update_details.emit(status)
except Exception as e:
# If parsing fails, just show basic status (maybe log the error)
- logger.error(f"Error parsing download details line: {line} -> {e}")
+ logger.exception(f"Error parsing download details line: {line} -> {e}")
pass # Keep basic status emission below if needed, or emit generic details
# Check for post-processing
diff --git a/src/core/ytsage_ffmpeg.py b/src/core/ytsage_ffmpeg.py
index c932d10..8befa7d 100644
--- a/src/core/ytsage_ffmpeg.py
+++ b/src/core/ytsage_ffmpeg.py
@@ -7,7 +7,7 @@ from pathlib import Path
import requests
-from src.core.ytsage_logging import logger
+from src.utils.ytsage_logger import logger
from src.utils.ytsage_constants import (
FFMPEG_7Z_DOWNLOAD_URL,
FFMPEG_7Z_SHA256_URL,
@@ -46,7 +46,7 @@ def download_file(url, dest_path, progress_callback=None) -> bool:
progress_callback(f"⚡ Downloading FFmpeg components... {progress}%")
return True
except requests.RequestException as e:
- logger.info(f"Download error: {str(e)}")
+ logger.info(f"Download error: {e}")
return False
@@ -80,7 +80,7 @@ def verify_sha256(file_path, expected_hash_url) -> bool:
logger.info(f"Actual: {actual_hash}")
return False
except Exception as e:
- logger.info(f"⚠️ SHA-256 verification error: {str(e)}")
+ logger.info(f"⚠️ SHA-256 verification error: {e}")
return False
@@ -128,7 +128,7 @@ def get_ffmpeg_path() -> str | Path:
ffmpeg_path = result.stdout.strip()
return ffmpeg_path
except Exception as e:
- logger.error(f"Error finding ffmpeg in PATH: {e}")
+ logger.exception(f"Error finding ffmpeg in PATH: {e}")
# If not found in PATH, check the installation directory
ffmpeg_install_path = get_ffmpeg_install_path()
@@ -171,7 +171,7 @@ def check_ffmpeg_installed() -> bool:
return True
return False
except Exception as e:
- logger.info(f"FFmpeg check error: {str(e)}")
+ logger.info(f"FFmpeg check error: {e}")
return False
@@ -219,7 +219,7 @@ def install_ffmpeg_windows() -> bool:
timeout=300,
) # 5-minute timeout
except Exception as e:
- logger.error(f"7z extraction failed: {str(e)}, trying zip fallback...")
+ logger.exception(f"7z extraction failed: {e}, trying zip fallback...")
use_7zip = False
else:
logger.error("SHA-256 verification failed for 7z file, trying zip fallback...")
@@ -236,7 +236,8 @@ def install_ffmpeg_windows() -> bool:
temp_file,
progress_callback=lambda msg: logger.debug(msg),
):
- raise Exception("Failed to download FFmpeg (both 7z and zip methods failed)")
+ logger.exception("Failed to download FFmpeg (both 7z and zip methods failed)")
+ return False
logger.info("Extracting FFmpeg components from zip archive...")
try:
@@ -245,7 +246,8 @@ def install_ffmpeg_windows() -> bool:
with zipfile.ZipFile(temp_file, "r") as zip_ref:
zip_ref.extractall(extract_dir)
except Exception as e:
- raise Exception(f"Extraction failed: {str(e)}")
+ logger.exception(f"Extraction failed: {e}")
+ return False
logger.info("Configuring system paths...")
# Add to System Path
@@ -265,13 +267,14 @@ def install_ffmpeg_windows() -> bool:
# Verify installation
if not check_ffmpeg_installed():
- raise Exception("FFmpeg installation verification failed")
+ logger.error("FFmpeg installation verification failed")
+ return False
logger.info("FFmpeg installation completed successfully!")
return True
except Exception as e:
- logger.error(f"Error installing FFmpeg: {str(e)}")
+ logger.exception(f"Error installing FFmpeg: {e}")
return False
@@ -298,12 +301,13 @@ def install_ffmpeg_macos() -> bool:
# Verify installation
if not check_ffmpeg_installed():
- raise Exception("FFmpeg installation verification failed")
+ logger.error("FFmpeg installation verification failed")
+ return False
return True
except Exception as e:
- logger.error(f"Error installing FFmpeg: {str(e)}")
+ logger.exception(f"Error installing FFmpeg: {e}")
return False
@@ -329,16 +333,18 @@ def install_ffmpeg_linux() -> bool:
# Universal snap package
subprocess.run(["sudo", "snap", "install", "ffmpeg"], check=True, timeout=300)
else:
- raise Exception("No supported package manager found")
+ logger.error("No supported package manager found")
+ return False
# Verify installation
if not check_ffmpeg_installed():
- raise Exception("FFmpeg installation verification failed")
+ logger.error("FFmpeg installation verification failed")
+ return False
return True
except Exception as e:
- logger.error(f"Error installing FFmpeg: {str(e)}")
+ logger.exception(f"Error installing FFmpeg: {e}")
return False
diff --git a/src/core/ytsage_logging.py b/src/core/ytsage_logging.py
deleted file mode 100644
index 7a17589..0000000
--- a/src/core/ytsage_logging.py
+++ /dev/null
@@ -1,238 +0,0 @@
-"""
-YTSage logging configuration using loguru.
-
-This module provides centralized logging configuration for the entire YTSage application.
-It replaces the inefficient print statements with structured logging using loguru.
-"""
-
-import sys
-from pathlib import Path
-
-from src.utils.ytsage_constants import APP_LOG_DIR
-
-# Try to import loguru, but handle case where it might not be available
-try:
- from loguru import logger
-
- LOGURU_AVAILABLE = True
-except ImportError:
- LOGURU_AVAILABLE = False
-
- # Create a dummy logger class that does nothing
- class DummyLogger:
- def info(self, *args, **kwargs):
- pass
-
- def debug(self, *args, **kwargs):
- pass
-
- def warning(self, *args, **kwargs):
- pass
-
- def error(self, *args, **kwargs):
- pass
-
- def critical(self, *args, **kwargs):
- pass
-
- def remove(self, *args, **kwargs):
- pass
-
- def add(self, *args, **kwargs):
- pass
-
- def bind(self, *args, **kwargs):
- return self
-
- @property
- def _core(self):
- class Core:
- handlers = []
-
- return Core()
-
- logger = DummyLogger()
-
-
-def setup_logging():
- """
- Configure loguru logging for YTSage application.
-
- Sets up multiple log levels and outputs:
- - Console output for INFO and above
- - File output for DEBUG and above
- - Separate error log file for ERROR and above
- """
-
- if not LOGURU_AVAILABLE:
- return logger
-
- # Remove default logger to avoid duplicate output
- try:
- logger.remove()
- except Exception:
- pass
-
- # Get the application data directory with fallbacks
- try:
- # logic moved to src\utils\ytsage_constants.py
- log_dir = APP_LOG_DIR
- except Exception:
- # Ultimate fallback - use current directory
- log_dir = Path.cwd() / "logs"
-
- # Create log directory if it doesn't exist
- try:
- log_dir.mkdir(parents=True, exist_ok=True)
- except Exception:
- # If we can't create the log directory, fall back to current directory
- log_dir = Path.cwd()
- try:
- log_dir.mkdir(exist_ok=True)
- except Exception:
- pass # If we still can't create it, we'll just log to console
-
- # Console handler - INFO and above, with colors
- # Check if stdout is available (it might be None in PyInstaller windowed apps)
- stdout_available = sys.stdout is not None
-
- if stdout_available:
- try:
- logger.add(
- sys.stdout,
- level="INFO",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
- colorize=True,
- catch=True,
- )
- except Exception:
- # Fallback to basic console logging without colors
- try:
- logger.add(
- sys.stdout,
- level="INFO",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
- catch=True,
- )
- except Exception:
- stdout_available = False
-
- # If stdout is not available, try stderr or skip console logging entirely
- if not stdout_available:
- try:
- if sys.stderr is not None:
- logger.add(
- sys.stderr,
- level="WARNING", # Only warnings and errors to stderr
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
- catch=True,
- )
- except Exception:
- # If even stderr fails, we'll rely only on file logging
- pass
-
- # Only add file handlers if we successfully created a log directory
- if log_dir and log_dir.exists():
- try:
- # Main log file - DEBUG and above, with rotation
- logger.add(
- log_dir / "ytsage.log",
- level="DEBUG",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
- rotation="10 MB", # Rotate when file reaches 10MB
- retention="7 days", # Keep logs for 7 days
- compression="zip", # Compress old logs
- catch=True,
- )
-
- # Error log file - ERROR and above only
- logger.add(
- log_dir / "ytsage_errors.log",
- level="ERROR",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
- rotation="5 MB",
- retention="30 days", # Keep error logs longer
- compression="zip",
- catch=True,
- )
- except Exception as e:
- # If file logging fails, just log to console
- logger.warning(f"Could not set up file logging: {e}")
-
- # Log startup message if we have any handlers
- if logger._core.handlers:
- logger.info("YTSage logging system initialized")
- if log_dir and log_dir.exists():
- logger.debug(f"Log directory: {log_dir}")
- else:
- logger.warning("File logging disabled - could not create log directory")
-
- # If no handlers were successfully added, add a null handler to prevent errors
- if not logger._core.handlers:
- # Add a minimal handler that just discards messages
- # This prevents loguru from complaining about no handlers
- import tempfile
-
- try:
- # Try to add a temporary file handler as last resort
- temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log"
- logger.add(temp_log, level="ERROR", catch=True)
- except Exception:
- # If even that fails, we're in a very restricted environment
- # loguru should handle this gracefully with its internal fallbacks
- pass
-
- return logger
-
-
-def get_logger(name: str | None = None):
- """
- Get a logger instance for a specific module.
-
- Args:
- name: Name of the module/component requesting the logger
-
- Returns:
- Configured logger instance
- """
- if name:
- return logger.bind(name=name)
- return logger
-
-
-# Initialize logging when module is imported - with maximum safety
-_setup_complete = False
-
-
-def safe_setup():
- """Safely initialize logging with multiple fallback strategies."""
- global _setup_complete
- if _setup_complete:
- return logger
-
- try:
- setup_logging()
- _setup_complete = True
- except Exception:
- # If all else fails, create an even simpler logger that just prints
- if LOGURU_AVAILABLE:
- try:
- logger.remove()
- except Exception:
- pass
-
- # At this point, just ensure we have something that won't crash
- _setup_complete = True
-
- return logger
-
-
-# Try to set up logging, but don't let it crash the module import
-try:
- safe_setup()
-except Exception:
- # Ultimate fallback - the module will still import successfully
- pass
-
-# Export the main logger for convenience
-__all__ = ["logger", "get_logger", "setup_logging"]
diff --git a/src/core/ytsage_utils.py b/src/core/ytsage_utils.py
index 4ac1d5e..64bf19f 100644
--- a/src/core/ytsage_utils.py
+++ b/src/core/ytsage_utils.py
@@ -4,28 +4,13 @@ import subprocess
import sys
import tempfile
import time
+from importlib.metadata import PackageNotFoundError
from pathlib import Path
-try:
- from importlib.metadata import version as importlib_version
- from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
-
- def get_version(package_name: str) -> str:
- return importlib_version(package_name)
-
- PackageNotFoundError = ImportlibPackageNotFoundError
-except ImportError:
- # Fallback for older Python versions
- import pkg_resources
- def get_version(package_name: str) -> str:
- return pkg_resources.get_distribution(package_name).version
- PackageNotFoundError = pkg_resources.DistributionNotFound
-
import requests
from packaging import version
from src.core.ytsage_ffmpeg import check_ffmpeg_installed, get_ffmpeg_install_path
-from src.core.ytsage_logging import logger
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import (
APP_CONFIG_FILE,
@@ -35,6 +20,25 @@ from src.utils.ytsage_constants import (
YTDLP_APP_BIN_PATH,
YTDLP_DOWNLOAD_URL,
)
+from src.utils.ytsage_logger import logger
+
+try:
+ from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
+ from importlib.metadata import version as importlib_version
+
+ def get_version(package_name: str) -> str:
+ return importlib_version(package_name)
+
+ PackageNotFoundError = ImportlibPackageNotFoundError
+except ImportError:
+ # Fallback for older Python versions
+ import pkg_resources
+
+ def get_version(package_name: str) -> str:
+ return pkg_resources.get_distribution(package_name).version
+
+ PackageNotFoundError = pkg_resources.DistributionNotFound
+
# Cache for version information to avoid delays
_version_cache = {
@@ -108,7 +112,7 @@ def load_version_cache_from_config() -> None:
if tool_name in _version_cache:
_version_cache[tool_name].update(cache_data)
except Exception as e:
- logger.error(f"Error loading version cache: {e}")
+ logger.exception(f"Error loading version cache: {e}")
def save_version_cache_to_config() -> None:
@@ -118,7 +122,7 @@ def save_version_cache_to_config() -> None:
config["cached_versions"] = _version_cache.copy()
save_config(config)
except Exception as e:
- logger.error(f"Error saving version cache: {e}")
+ logger.exception(f"Error saving version cache: {e}")
def get_ytdlp_version_cached() -> str:
@@ -140,7 +144,7 @@ def get_ytdlp_version_cached() -> str:
return version_info
except Exception as e:
- logger.error(f"Error getting cached yt-dlp version: {e}")
+ logger.exception(f"Error getting cached yt-dlp version: {e}")
return "Error getting version"
@@ -164,7 +168,7 @@ def get_ffmpeg_version_cached() -> str:
return version_info
except Exception as e:
- logger.error(f"Error getting cached FFmpeg version: {e}")
+ logger.exception(f"Error getting cached FFmpeg version: {e}")
return "Error getting version"
@@ -182,7 +186,7 @@ def refresh_version_cache(force=False) -> bool:
return True
except Exception as e:
- logger.error(f"Error refreshing version cache: {e}")
+ logger.exception(f"Error refreshing version cache: {e}")
return False
@@ -215,7 +219,7 @@ def get_ytdlp_version_direct(yt_dlp_path=None) -> str:
else:
return "Error getting version"
except Exception as e:
- logger.error(f"Error getting yt-dlp version: {e}")
+ logger.exception(f"Error getting yt-dlp version: {e}")
return "Error getting version"
@@ -269,10 +273,10 @@ def get_ffmpeg_version_direct() -> str:
return "Unknown version"
return "Not found"
except Exception as e:
- logger.error(f"Error getting FFmpeg version from install path: {e}")
+ logger.exception(f"Error getting FFmpeg version from install path: {e}")
return "Not found"
except Exception as e:
- logger.error(f"Error getting FFmpeg version: {e}")
+ logger.exception(f"Error getting FFmpeg version: {e}")
return "Error getting version"
@@ -308,7 +312,7 @@ def load_config() -> dict:
config[key] = value
return config
except (json.JSONDecodeError, UnicodeError, Exception) as e:
- logger.error(f"Error reading config file: {e}")
+ logger.exception(f"Error reading config file: {e}")
# If config file is corrupted, create a new one with defaults
save_config(default_config)
@@ -322,7 +326,7 @@ def save_config(config) -> bool:
json.dump(config, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
- logger.error(f"Error saving config: {e}")
+ logger.exception(f"Error saving config: {e}")
return False
@@ -342,7 +346,7 @@ def check_ffmpeg() -> bool:
os.environ["PATH"] = f"{ffmpeg_path}{os.pathsep}{os.environ.get('PATH', '')}"
return True
except Exception as e:
- logger.error(f"Error updating PATH: {e}")
+ logger.exception(f"Error updating PATH: {e}")
return False
# For macOS, check common paths
@@ -359,13 +363,13 @@ def check_ffmpeg() -> bool:
os.environ["PATH"] = f"{ffmpeg_dir}{os.pathsep}{os.environ.get('PATH', '')}"
return True
except Exception as e:
- logger.error(f"Error updating PATH: {e}")
+ logger.exception(f"Error updating PATH: {e}")
continue
return False
except Exception as e:
- logger.error(f"Error checking FFmpeg: {e}")
+ logger.exception(f"Error checking FFmpeg: {e}")
return False
@@ -381,7 +385,7 @@ def load_saved_path(main_window_instance) -> None:
main_window_instance.last_path = saved_path
return
except (json.JSONDecodeError, UnicodeError) as e:
- logger.error(f"Error reading config file: {e}")
+ logger.exception(f"Error reading config file: {e}")
# If config file is corrupted, try to remove it
try:
APP_CONFIG_FILE.unlink(missing_ok=True)
@@ -397,7 +401,7 @@ def load_saved_path(main_window_instance) -> None:
main_window_instance.last_path = tempfile.gettempdir()
except Exception as e:
- logger.error(f"Error loading saved settings: {e}")
+ logger.exception(f"Error loading saved settings: {e}")
main_window_instance.last_path = tempfile.gettempdir()
@@ -409,7 +413,7 @@ def save_path(main_window_instance, path) -> bool:
try:
Path(path).mkdir(exist_ok=True)
except Exception as e:
- logger.error(f"Error creating directory: {e}")
+ logger.exception(f"Error creating directory: {e}")
return False
if not os.access(path, os.W_OK):
@@ -423,7 +427,7 @@ def save_path(main_window_instance, path) -> bool:
return True
except Exception as e:
- logger.error(f"Error saving settings: {e}")
+ logger.exception(f"Error saving settings: {e}")
return False
@@ -484,13 +488,13 @@ def update_yt_dlp() -> bool:
logger.info("yt-dlp binary successfully updated")
return True
except Exception as e:
- logger.error(f"Error replacing yt-dlp binary: {e}")
+ logger.exception(f"Error replacing yt-dlp binary: {e}")
return False
else:
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
return False
except Exception as e:
- logger.error(f"Error downloading yt-dlp update: {e}")
+ logger.exception(f"Error downloading yt-dlp update: {e}")
return False
else:
# We're using a system-installed yt-dlp, use pip to update
@@ -540,9 +544,9 @@ def update_yt_dlp() -> bool:
else:
logger.info(f"Failed to get latest version info: HTTP {response.status_code}")
except Exception as e:
- logger.error(f"Error checking for yt-dlp updates: {e}")
+ logger.exception(f"Error checking for yt-dlp updates: {e}")
except Exception as e:
- logger.info(f"Unexpected error during yt-dlp update: {e}")
+ logger.exception(f"Unexpected error during yt-dlp update: {e}")
return False
@@ -573,7 +577,7 @@ def should_check_for_auto_update() -> bool:
return False
except Exception as e:
- logger.error(f"Error checking auto-update schedule: {e}")
+ logger.exception(f"Error checking auto-update schedule: {e}")
return False
@@ -602,9 +606,7 @@ def check_and_update_ytdlp_auto() -> bool:
logger.info(f"Latest yt-dlp version: {latest_version}")
# Compare versions
- from packaging import version as version_parser
-
- if version_parser.parse(latest_version) > version_parser.parse(current_version):
+ if version.parse(latest_version) > version.parse(current_version):
logger.info(f"Auto-updating yt-dlp from {current_version} to {latest_version}...")
# Perform the update
@@ -630,11 +632,11 @@ def check_and_update_ytdlp_auto() -> bool:
logger.info(f"Network error during auto-update check: {e}")
return False
except Exception as e:
- logger.error(f"Error during auto-update check: {e}")
+ logger.exception(f"Error during auto-update check: {e}")
return False
except Exception as e:
- logger.info(f"Critical error in auto-update: {e}")
+ logger.critical(f"Critical error in auto-update: {e}", exc_info=True)
return False
@@ -657,77 +659,103 @@ def update_auto_update_settings(enabled, frequency) -> bool:
save_config(config)
return True
except Exception as e:
- logger.error(f"Error updating auto-update settings: {e}")
+ logger.exception(f"Error updating auto-update settings: {e}")
return False
def parse_yt_dlp_error(error_message: str) -> str:
"""
Parse yt-dlp error messages and return user-friendly error messages.
-
+
Args:
error_message: The raw error message from yt-dlp
-
+
Returns:
str: A user-friendly error message with actionable advice
"""
- error_str = str(error_message).lower()
-
+ error_str = error_message.lower()
+
# Private video errors
- if any(keyword in error_str for keyword in ['private video', 'login_required', 'sign in if you']):
- return ("This is a private video. You can download it by logging into your account using cookies.\n"
- "Go to 'Custom Options' → 'Login with Cookies' → 'Extract cookies from browser' to authenticate.")
-
+ if any(keyword in error_str for keyword in ["private video", "login_required", "sign in if you"]):
+ return (
+ "This is a private video. You can download it by logging into your account using cookies.\n"
+ "Go to 'Custom Options' → 'Login with Cookies' → 'Extract cookies from browser' to authenticate."
+ )
+
# Age-restricted content
- if any(keyword in error_str for keyword in ['age restricted', 'age-restricted', 'confirm your age']):
- return ("This video is age-restricted. You need to be logged in to access it.\n"
- "Use 'Custom Options' → 'Login with Cookies' to authenticate with your account.")
-
+ if any(keyword in error_str for keyword in ["age restricted", "age-restricted", "confirm your age"]):
+ return (
+ "This video is age-restricted. You need to be logged in to access it.\n"
+ "Use 'Custom Options' → 'Login with Cookies' to authenticate with your account."
+ )
+
# Geo-blocked content
- if any(keyword in error_str for keyword in ['not available in your country', 'geo-blocked', 'video is not available', 'not made this video available in your country']):
- return ("This video is not available in your region (geo-blocked).\n"
- "You may need to use a VPN or the video might be restricted in your country.")
-
+ if any(
+ keyword in error_str
+ for keyword in [
+ "not available in your country",
+ "geo-blocked",
+ "video is not available",
+ "not made this video available in your country",
+ ]
+ ):
+ return (
+ "This video is not available in your region (geo-blocked).\n"
+ "You may need to use a VPN or the video might be restricted in your country."
+ )
+
# Removed/deleted videos
- if any(keyword in error_str for keyword in ['video unavailable', 'this video has been removed', 'video does not exist']):
- return ("This video has been removed or is no longer available.\n"
- "The video may have been deleted by the uploader or removed due to policy violations.")
-
+ if any(keyword in error_str for keyword in ["video unavailable", "this video has been removed", "video does not exist"]):
+ return (
+ "This video has been removed or is no longer available.\n"
+ "The video may have been deleted by the uploader or removed due to policy violations."
+ )
+
# Live stream errors
- if any(keyword in error_str for keyword in ['live stream', 'livestream', 'is live']):
- return ("This is a live stream that cannot be downloaded while active.\n"
- "Wait for the stream to end, then try downloading the archived version.")
-
+ if any(keyword in error_str for keyword in ["live stream", "livestream", "is live"]):
+ return (
+ "This is a live stream that cannot be downloaded while active.\n"
+ "Wait for the stream to end, then try downloading the archived version."
+ )
+
# Playlist errors
- if any(keyword in error_str for keyword in ['playlist', 'no entries']):
- return ("Unable to access this playlist. It may be private, deleted, or empty.\n"
- "Check if the playlist exists and is publicly accessible.")
-
+ if any(keyword in error_str for keyword in ["playlist", "no entries"]):
+ return (
+ "Unable to access this playlist. It may be private, deleted, or empty.\n"
+ "Check if the playlist exists and is publicly accessible."
+ )
+
# Network/connection errors
- if any(keyword in error_str for keyword in ['network error', 'connection', 'timeout', 'unable to download']):
- return ("Network connection error. Please check your internet connection and try again.\n"
- "If the problem persists, the video server might be temporarily unavailable.")
-
+ if any(keyword in error_str for keyword in ["network error", "connection", "timeout", "unable to download"]):
+ return (
+ "Network connection error. Please check your internet connection and try again.\n"
+ "If the problem persists, the video server might be temporarily unavailable."
+ )
+
# Invalid URL
- if any(keyword in error_str for keyword in ['invalid url', 'unsupported url', 'no video found']):
- return ("Invalid or unsupported URL. Please check the link and try again.\n"
- "Make sure you're using a valid YouTube, Vimeo, or other supported platform URL.")
-
+ if any(keyword in error_str for keyword in ["invalid url", "unsupported url", "no video found"]):
+ return (
+ "Invalid or unsupported URL. Please check the link and try again.\n"
+ "Make sure you're using a valid YouTube, Vimeo, or other supported platform URL."
+ )
+
# YouTube premium content
- if any(keyword in error_str for keyword in ['youtube premium', 'premium', 'members only']):
- return ("This content requires YouTube Premium or channel membership.\n"
- "You need to be logged in with an account that has access to this content.")
-
+ if any(keyword in error_str for keyword in ["youtube premium", "premium", "members only"]):
+ return (
+ "This content requires YouTube Premium or channel membership.\n"
+ "You need to be logged in with an account that has access to this content."
+ )
+
# Copyright/DMCA
- if any(keyword in error_str for keyword in ['copyright', 'dmca', 'blocked']):
- return ("This video is blocked due to copyright claims.\n"
- "The content owner has restricted access to this video.")
-
+ if any(keyword in error_str for keyword in ["copyright", "dmca", "blocked"]):
+ return "This video is blocked due to copyright claims.\n" "The content owner has restricted access to this video."
+
# Extraction errors (could be temporary)
- if any(keyword in error_str for keyword in ['unable to extract', 'extraction failed']):
- return ("Failed to extract video information. This might be a temporary issue.\n"
- "Please try again in a few minutes, or check if the video link is correct.")
-
+ if any(keyword in error_str for keyword in ["unable to extract", "extraction failed"]):
+ return (
+ "Failed to extract video information. This might be a temporary issue.\n"
+ "Please try again in a few minutes, or check if the video link is correct."
+ )
+
# Generic fallback with the original error for debugging
- return (f"Could not extract video information. Please check your link.\n"
- f"Technical details: {error_message}")
+ return f"Could not extract video information. Please check your link.\n" f"Technical details: {error_message}"
diff --git a/src/core/ytsage_yt_dlp.py b/src/core/ytsage_yt_dlp.py
index b0b2a30..ed76240 100644
--- a/src/core/ytsage_yt_dlp.py
+++ b/src/core/ytsage_yt_dlp.py
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
QWidget,
)
-from src.core.ytsage_logging import logger
+from src.utils.ytsage_logger import logger
from src.utils.ytsage_constants import (
APP_BIN_DIR,
ICON_PATH,
@@ -94,7 +94,7 @@ class YtdlpSetupDialog(QDialog):
# icon_path logic moved to src\utils\ytsage_constants.py
icon_path = ICON_PATH
if Path.exists(icon_path):
- self.setWindowIcon(QIcon(icon_path.as_posix()))
+ self.setWindowIcon(QIcon(str(icon_path)))
self.init_ui()
@@ -390,11 +390,11 @@ class YtdlpSetupDialog(QDialog):
self.setup_complete.emit(target_path)
self.accept()
except Exception as copy_error:
- logger.debug(f"Error copying file: {str(copy_error)}")
+ logger.debug(f"Error copying file: {copy_error}", exc_info=True)
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Setup Error")
- error_dialog.setText(f"Error copying yt-dlp to app directory: {str(copy_error)}")
+ error_dialog.setText(f"Error copying yt-dlp to app directory: {copy_error}")
error_dialog.setStyleSheet(
"""
QMessageBox {
@@ -448,11 +448,11 @@ class YtdlpSetupDialog(QDialog):
)
error_dialog.exec()
except Exception as e:
- logger.debug(f"Exception during verification: {str(e)}")
+ logger.debug(f"Exception during verification: {e}", exc_info=True)
error_dialog = QMessageBox(self)
error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setWindowTitle("Error")
- error_dialog.setText(f"Error verifying yt-dlp executable: {str(e)}")
+ error_dialog.setText(f"Error verifying yt-dlp executable: {e}")
error_dialog.setStyleSheet(
"""
QMessageBox {
@@ -492,7 +492,7 @@ def check_ytdlp_binary() -> Optional[Path]:
os.chmod(exe_path, 0o755)
logger.info(f"Fixed permissions on yt-dlp at {exe_path}")
except Exception as e:
- logger.warning(f"Could not set executable permissions on {exe_path}: {e}")
+ logger.exception(f"Could not set executable permissions on {exe_path}: {e}")
return exe_path
# If not found in app directory, check if yt-dlp is available in PATH
@@ -517,7 +517,7 @@ def check_ytdlp_binary() -> Optional[Path]:
logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}")
return Path(yt_dlp_path)
except Exception as e:
- logger.error(f"Error checking for yt-dlp in PATH: {e}")
+ logger.exception(f"Error checking for yt-dlp in PATH: {e}")
# We're only interested in our app-specific installation or system PATH
return None
diff --git a/src/gui/ytsage_gui_dialogs/__init__.py b/src/gui/ytsage_gui_dialogs/__init__.py
index 5d60dd3..fe233f3 100644
--- a/src/gui/ytsage_gui_dialogs/__init__.py
+++ b/src/gui/ytsage_gui_dialogs/__init__.py
@@ -13,10 +13,7 @@ This package contains all dialog classes organized by functionality:
# Re-export all dialog classes for backward compatibility
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_base import AboutDialog, LogWindow
-from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import (
- CustomOptionsDialog,
- TimeRangeDialog,
-)
+from src.gui.ytsage_gui_dialogs.ytsage_dialogs_custom import CustomOptionsDialog, TimeRangeDialog
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_ffmpeg import FFmpegCheckDialog, FFmpegInstallThread
from src.gui.ytsage_gui_dialogs.ytsage_dialogs_selection import (
PlaylistSelectionDialog,
@@ -30,21 +27,21 @@ __all__ = [
# Base dialogs
"LogWindow",
"AboutDialog",
-
+
# Settings dialogs
"DownloadSettingsDialog",
"AutoUpdateSettingsDialog",
-
+
# Update dialogs and threads
"VersionCheckThread",
"UpdateThread",
"YTDLPUpdateDialog",
"AutoUpdateThread",
-
+
# FFmpeg dialogs
"FFmpegInstallThread",
"FFmpegCheckDialog",
-
+
# Selection dialogs
"SubtitleSelectionDialog",
"PlaylistSelectionDialog",
diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
index c45e39c..d38c8c2 100644
--- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
@@ -3,6 +3,8 @@ Base dialogs for YTSage application.
Contains basic utility dialogs like LogWindow and AboutDialog.
"""
+from datetime import datetime
+
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QDialog,
@@ -155,7 +157,7 @@ class AboutDialog(QDialog):
layout.addWidget(title_label)
version_label = QLabel(
- f"Version {getattr(self._parent, 'version', '4.8.0b')}"
+ f"Version {getattr(self._parent, 'version', '4.8.3')}"
)
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version_label)
@@ -392,8 +394,6 @@ class AboutDialog(QDialog):
last_check = ytdlp_cache.get("last_check", 0)
cache_status = ""
if last_check > 0:
- from datetime import datetime
-
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
cache_status = f" ({cache_time})" # Increased from 9px
@@ -426,8 +426,6 @@ class AboutDialog(QDialog):
last_check = ffmpeg_cache.get("last_check", 0)
cache_status = ""
if last_check > 0 and ffmpeg_found:
- from datetime import datetime
-
cache_time = datetime.fromtimestamp(last_check).strftime("%H:%M")
cache_status = f" ({cache_time})" # Increased from 9px
diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
index 778fbc9..4f166f0 100644
--- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_custom.py
@@ -8,7 +8,7 @@ import threading
from pathlib import Path
from typing import TYPE_CHECKING, cast
-from PySide6.QtCore import Q_ARG, QMetaObject, Qt, Signal, QObject
+from PySide6.QtCore import Q_ARG, QMetaObject, QObject, Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -31,31 +31,24 @@ from PySide6.QtWidgets import (
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import YTDLP_DOCS_URL
-try:
- import yt_dlp
-
- YT_DLP_AVAILABLE = True
-except ImportError:
- YT_DLP_AVAILABLE = False
-
if TYPE_CHECKING:
from src.gui.ytsage_gui_main import YTSageApp # only for type hints (no runtime import)
class CommandWorker(QObject):
"""Worker class for running yt-dlp commands in a separate thread"""
-
+
# Signals for communicating with the main thread
output_received = Signal(str) # For command output lines
command_finished = Signal(bool, int) # For completion (success, exit_code)
error_occurred = Signal(str) # For errors
-
+
def __init__(self, command, url, path):
super().__init__()
self.command = command
self.url = url
self.path = path
-
+
def run_command(self):
"""Run the yt-dlp command and emit signals for output"""
try:
@@ -65,11 +58,11 @@ class CommandWorker(QObject):
# Build the full command
yt_dlp_path = get_yt_dlp_path()
base_cmd = [yt_dlp_path] + args
-
+
# Add download path if specified
if self.path:
base_cmd.extend(["-P", self.path])
-
+
# Add URL at the end
base_cmd.append(self.url)
@@ -94,14 +87,14 @@ class CommandWorker(QObject):
ret = proc.wait()
self.output_received.emit("=" * 50)
-
+
if ret != 0:
self.output_received.emit(f"❌ Command failed with exit code {ret}")
self.command_finished.emit(False, ret)
else:
self.output_received.emit("✅ Command completed successfully!")
self.command_finished.emit(True, ret)
-
+
except Exception as e:
self.output_received.emit("=" * 50)
self.error_occurred.emit(f"❌ Error executing command: {str(e)}")
@@ -160,7 +153,7 @@ class CustomOptionsDialog(QDialog):
# Convert Path to string properly and validate
cookie_path_str = str(self._parent.cookie_file_path)
# Only set if it looks like a valid path (more than just a drive letter)
- if len(cookie_path_str) > 3 and not cookie_path_str.endswith(':'):
+ if len(cookie_path_str) > 3 and not cookie_path_str.endswith(":"):
self.cookie_path_input.setText(cookie_path_str)
path_layout.addWidget(self.cookie_path_input)
@@ -175,9 +168,7 @@ class CustomOptionsDialog(QDialog):
self.cookie_browser_group = QGroupBox("Browser Selection")
browser_layout = QVBoxLayout(self.cookie_browser_group)
- browser_help = QLabel(
- "Select the browser to extract cookies from. Make sure the browser is closed before extraction."
- )
+ browser_help = QLabel("Select the browser to extract cookies from. Make sure the browser is closed before extraction.")
browser_help.setWordWrap(True)
browser_help.setStyleSheet("color: #999999; font-size: 11px;")
browser_layout.addWidget(browser_help)
@@ -186,16 +177,7 @@ class CustomOptionsDialog(QDialog):
browser_select_layout.addWidget(QLabel("Browser:"))
self.browser_combo = QComboBox()
- self.browser_combo.addItems([
- "chrome",
- "firefox",
- "safari",
- "edge",
- "opera",
- "brave",
- "chromium",
- "vivaldi"
- ])
+ self.browser_combo.addItems(["chrome", "firefox", "safari", "edge", "opera", "brave", "chromium", "vivaldi"])
browser_select_layout.addWidget(self.browser_combo)
browser_layout.addLayout(browser_select_layout)
@@ -261,10 +243,7 @@ class CustomOptionsDialog(QDialog):
# Command input
self.command_input = QPlainTextEdit()
- self.command_input.setPlaceholderText(
- "Enter yt-dlp arguments here...\n\n"
- "e.g. --extract-audio --audio-format mp3"
- )
+ self.command_input.setPlaceholderText("Enter yt-dlp arguments here...\n\n" "e.g. --extract-audio --audio-format mp3")
self.command_input.setMinimumHeight(80) # Reduced further from 100
self.command_input.setStyleSheet(
"""
@@ -288,7 +267,7 @@ class CustomOptionsDialog(QDialog):
# Button layout
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
-
+
clear_btn = QPushButton("Clear")
clear_btn.clicked.connect(lambda: self.command_input.clear())
clear_btn.setStyleSheet(
@@ -308,15 +287,15 @@ class CustomOptionsDialog(QDialog):
"""
)
button_layout.addWidget(clear_btn)
-
+
button_layout.addStretch() # Push run button to the right
-
+
# Run command button
self.run_btn = QPushButton("Run Command")
self.run_btn.clicked.connect(self.run_custom_command)
self.run_btn.setDefault(True)
button_layout.addWidget(self.run_btn)
-
+
command_layout.addLayout(button_layout)
# Output label
@@ -469,18 +448,18 @@ class CustomOptionsDialog(QDialog):
if hasattr(self._parent, "browser_cookies_option") and self._parent.browser_cookies_option:
# Browser cookies are active
self.cookie_browser_radio.setChecked(True)
- browser_parts = self._parent.browser_cookies_option.split(':')
+ browser_parts = self._parent.browser_cookies_option.split(":")
browser = browser_parts[0]
profile = browser_parts[1] if len(browser_parts) > 1 else ""
-
+
# Set browser selection
index = self.browser_combo.findText(browser)
if index >= 0:
self.browser_combo.setCurrentIndex(index)
-
+
# Set profile if any
self.profile_input.setText(profile)
-
+
self.cookie_status.setText(f"Browser cookies active: {self._parent.browser_cookies_option}")
self.cookie_status.setStyleSheet("color: #00cc00; font-style: italic;")
elif hasattr(self._parent, "cookie_file_path") and self._parent.cookie_file_path:
@@ -533,7 +512,7 @@ class CustomOptionsDialog(QDialog):
if self.cookie_browser_radio.isChecked():
browser = self.browser_combo.currentText()
profile = self.profile_input.text().strip()
-
+
if profile:
return f"{browser}:{profile}"
else:
@@ -571,12 +550,12 @@ class CustomOptionsDialog(QDialog):
# Create worker and thread
self.worker = CommandWorker(command, url, path)
self.worker_thread = threading.Thread(target=self.worker.run_command, daemon=True)
-
+
# Connect worker signals to our slots
self.worker.output_received.connect(self.on_output_received)
self.worker.command_finished.connect(self.on_command_finished)
self.worker.error_occurred.connect(self.on_error_occurred)
-
+
# Start the thread
self.worker_thread.start()
diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
index 0265e9b..a395103 100644
--- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_ffmpeg.py
@@ -48,7 +48,7 @@ class FFmpegCheckDialog(QDialog):
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
- self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
+ self.setWindowIcon(QIcon(str(ICON_PATH)))
layout = QVBoxLayout(self)
layout.setSpacing(15)
diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
index 0fc0ef1..bac857d 100644
--- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_settings.py
@@ -3,11 +3,13 @@ Settings-related dialogs for YTSage application.
Contains dialogs for configuring download settings and auto-update preferences.
"""
+import threading
import time
from datetime import datetime
import requests
-from PySide6.QtCore import Qt
+from packaging import version as version_parser
+from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
@@ -25,13 +27,13 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
-from src.core.ytsage_logging import logger
from src.core.ytsage_utils import (
check_and_update_ytdlp_auto,
get_auto_update_settings,
get_ytdlp_version,
update_auto_update_settings,
)
+from src.utils.ytsage_logger import logger
class DownloadSettingsDialog(QDialog):
@@ -164,7 +166,7 @@ class DownloadSettingsDialog(QDialog):
path_group_box = QGroupBox("Download Path")
path_layout = QVBoxLayout()
- self.path_display = QLabel(self.current_path)
+ self.path_display = QLabel(str(self.current_path))
self.path_display.setWordWrap(True)
self.path_display.setStyleSheet(
"QLabel { color: #ffffff; padding: 5px; border: 1px solid #1b2021; border-radius: 4px; background-color: #1b2021; }"
@@ -246,7 +248,7 @@ class DownloadSettingsDialog(QDialog):
layout.addWidget(button_box)
def browse_new_path(self) -> None:
- new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", self.current_path)
+ new_path = QFileDialog.getExistingDirectory(self, "Select Download Directory", str(self.current_path))
if new_path:
self.current_path = new_path
self.path_display.setText(self.current_path)
@@ -329,8 +331,6 @@ class DownloadSettingsDialog(QDialog):
current_version = current_version.replace("_", ".")
latest_version = latest_version.replace("_", ".")
- from packaging import version as version_parser
-
if version_parser.parse(latest_version) > version_parser.parse(current_version):
msg_box = self._create_styled_message_box(
QMessageBox.Icon.Information,
@@ -349,7 +349,7 @@ class DownloadSettingsDialog(QDialog):
msg_box = self._create_styled_message_box(
QMessageBox.Icon.Warning,
"Update Check",
- f"Error checking for updates: {str(e)}",
+ f"Error checking for updates: {e}",
)
msg_box.exec()
@@ -381,7 +381,7 @@ class DownloadSettingsDialog(QDialog):
else:
QMessageBox.warning(self, "Error", "Failed to save auto-update settings.")
except Exception as e:
- QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {str(e)}")
+ QMessageBox.critical(self, "Error", f"Error saving auto-update settings: {e}")
# Call the parent accept method to close the dialog
super().accept()
@@ -582,7 +582,7 @@ class AutoUpdateSettingsDialog(QDialog):
self.on_enable_toggled(settings["enabled"])
except Exception as e:
- logger.error(f"Error loading auto-update settings: {e}")
+ logger.exception(f"Error loading auto-update settings: {e}")
def update_next_check_label(self) -> None:
"""Update the next check label based on current settings."""
@@ -616,7 +616,7 @@ class AutoUpdateSettingsDialog(QDialog):
except Exception as e:
self.next_check_label.setText("Next check: Error calculating")
- logger.error(f"Error calculating next check time: {e}")
+ logger.exception(f"Error calculating next check time: {e}")
def on_enable_toggled(self, enabled) -> None:
"""Handle enable/disable checkbox toggle."""
@@ -646,16 +646,12 @@ class AutoUpdateSettingsDialog(QDialog):
result = check_and_update_ytdlp_auto()
# Update UI in main thread
- from PySide6.QtCore import QTimer
-
QTimer.singleShot(0, lambda: self.manual_check_finished(result))
except Exception as e:
- logger.error(f"Error during manual check: {e}")
+ logger.exception(f"Error during manual check: {e}")
QTimer.singleShot(0, lambda: self.manual_check_finished(False))
# Run in separate thread to avoid blocking UI
- import threading
-
threading.Thread(target=check_in_thread, daemon=True).start()
def _create_styled_message_box(self, icon, title, text) -> QMessageBox:
@@ -738,6 +734,6 @@ class AutoUpdateSettingsDialog(QDialog):
)
msg_box.exec()
except Exception as e:
- logger.error(f"Error saving auto-update settings: {e}")
- msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {str(e)}")
+ logger.exception(f"Error saving auto-update settings: {e}")
+ msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, "Error", f"❌ Error saving settings: {e}")
msg_box.exec()
diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
index 0638e2b..7b94f44 100644
--- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
+++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
@@ -7,6 +7,7 @@ import os
import subprocess
import sys
import time
+from importlib.metadata import PackageNotFoundError
from pathlib import Path
import requests
@@ -14,24 +15,26 @@ from packaging import version
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout
-from src.core.ytsage_logging import logger
from src.core.ytsage_utils import get_ytdlp_version, load_config, save_config
from src.core.ytsage_yt_dlp import get_yt_dlp_path
from src.utils.ytsage_constants import OS_NAME, SUBPROCESS_CREATIONFLAGS, YTDLP_APP_BIN_PATH, YTDLP_DOWNLOAD_URL
+from src.utils.ytsage_logger import logger
try:
- from importlib.metadata import version as importlib_version
from importlib.metadata import PackageNotFoundError as ImportlibPackageNotFoundError
-
+ from importlib.metadata import version as importlib_version
+
def get_version(package_name: str) -> str:
return importlib_version(package_name)
-
+
PackageNotFoundError = ImportlibPackageNotFoundError
except ImportError:
# Fallback for older Python versions
import pkg_resources
+
def get_version(package_name: str) -> str:
return pkg_resources.get_distribution(package_name).version
+
PackageNotFoundError = pkg_resources.DistributionNotFound
try:
@@ -71,7 +74,7 @@ class VersionCheckThread(QThread):
else:
error_message = "yt-dlp not available."
self.finished.emit(current_version, latest_version, error_message)
- return
+ return
except subprocess.TimeoutExpired:
# Try fallback if timeout
if YT_DLP_AVAILABLE:
@@ -162,11 +165,11 @@ class UpdateThread(QThread):
error_message = "❌ Failed to update yt-dlp. Please try again or check your internet connection."
except requests.RequestException as e:
- error_message = f"❌ Network error during update: {str(e)}"
+ error_message = f"❌ Network error during update: {e}"
self.update_status.emit(error_message)
success = False
except Exception as e:
- error_message = f"❌ Update failed: {str(e)}"
+ error_message = f"❌ Update failed: {e}"
self.update_status.emit(error_message)
success = False
@@ -207,7 +210,7 @@ class UpdateThread(QThread):
return False
except Exception as e:
- logger.error(f"UpdateThread: Unexpected error during update: {e}", exc_info=True)
+ logger.exception(f"UpdateThread: Unexpected error during update: {e}")
self.update_status.emit(f"❌ Unexpected error during update: {e}")
return False
@@ -553,10 +556,7 @@ class AutoUpdateThread(QThread):
logger.warning(f"AutoUpdateThread: Network error during auto-update check: {e}")
self.update_finished.emit(False, f"Network error: {e}")
except Exception as e:
- logger.error(
- f"AutoUpdateThread: Error during auto-update check: {e}",
- exc_info=True,
- )
+ logger.exception(f"AutoUpdateThread: Error during auto-update check: {e}", exc_info=True)
self.update_finished.emit(False, f"Update check error: {e}")
except Exception as e:
@@ -595,7 +595,7 @@ class AutoUpdateThread(QThread):
return self._update_via_pip()
except Exception as e:
- logger.error(f"AutoUpdateThread: Error in _perform_update: {e}", exc_info=True)
+ logger.exception(f"AutoUpdateThread: Error in _perform_update: {e}")
return False
def _update_binary(self, yt_dlp_path: Path) -> bool:
@@ -629,7 +629,7 @@ class AutoUpdateThread(QThread):
return False
except Exception as e:
- logger.error(f"AutoUpdateThread: Unexpected error during update: {e}", exc_info=True)
+ logger.exception(f"AutoUpdateThread: Unexpected error during update: {e}")
return False
def _update_via_pip(self) -> bool:
@@ -684,5 +684,5 @@ class AutoUpdateThread(QThread):
return True
except Exception as e:
- logger.error(f"AutoUpdateThread: Pip update failed: {e}", exc_info=True)
+ logger.exception(f"AutoUpdateThread: Pip update failed: {e}")
return False
diff --git a/src/gui/ytsage_gui_format_table.py b/src/gui/ytsage_gui_format_table.py
index 828cf3c..012c14e 100644
--- a/src/gui/ytsage_gui_format_table.py
+++ b/src/gui/ytsage_gui_format_table.py
@@ -1,7 +1,12 @@
+from typing import TYPE_CHECKING, cast
+
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QCheckBox, QHBoxLayout, QHeaderView, QSizePolicy, QTableWidget, QTableWidgetItem, QWidget
+if TYPE_CHECKING:
+ from src.gui.ytsage_gui_main import YTSageApp
+
class FormatSignals(QObject):
format_update = Signal(list)
@@ -9,8 +14,9 @@ class FormatSignals(QObject):
class FormatTableMixin:
def setup_format_table(self) -> QTableWidget:
- self.format_signals = FormatSignals()
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+ self.format_signals = FormatSignals()
# Format table with improved styling
self.format_table = QTableWidget()
self.format_table.setColumnCount(8)
@@ -124,6 +130,8 @@ class FormatTableMixin:
return self.format_table
def filter_formats(self) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
if not hasattr(self, "all_formats"):
return
@@ -165,6 +173,8 @@ class FormatTableMixin:
self.format_signals.format_update.emit(filtered_formats)
def _update_format_table(self, formats) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
self.format_table.setRowCount(0)
self.format_checkboxes.clear()
@@ -331,22 +341,30 @@ class FormatTableMixin:
self.format_table.setItem(row, 7, notes_item)
def handle_checkbox_click(self, clicked_checkbox) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
for checkbox in self.format_checkboxes:
if checkbox != clicked_checkbox:
checkbox.setChecked(False)
def get_selected_format(self):
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
for checkbox in self.format_checkboxes:
if checkbox.isChecked():
return checkbox.format_id
return None
def update_format_table(self, formats) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
self.all_formats = formats
self.format_signals.format_update.emit(formats)
def get_quality_label(self, format_info) -> str:
"""Determine quality label based on format information"""
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
if format_info.get("vcodec") == "none":
# Audio quality
abr = format_info.get("abr", 0)
@@ -383,17 +401,12 @@ class FormatTableMixin:
def _get_format_notes(self, format_info) -> str:
"""Generate helpful format notes based on format info."""
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
notes = []
# Add storage indicator with more granular categories
file_size = format_info.get("filesize") or format_info.get("filesize_approx", 0)
- resolution = format_info.get("resolution", "")
- height = 0
- if resolution:
- try:
- height = int(resolution.split("x")[1])
- except:
- pass
# Better file size categories
if file_size > 50 * 1024 * 1024: # Over 50MB
diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py
index b0da1ed..5134433 100644
--- a/src/gui/ytsage_gui_main.py
+++ b/src/gui/ytsage_gui_main.py
@@ -5,9 +5,10 @@ import webbrowser
from pathlib import Path
import markdown
+import pyglet
import requests
from packaging import version
-from PySide6.QtCore import Q_ARG, QMetaObject, Qt
+from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -28,9 +29,7 @@ from PySide6.QtWidgets import (
)
from src.core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
-from src.core.ytsage_logging import logger
-from src.core.ytsage_utils import check_ffmpeg # Import utility functions
-from src.core.ytsage_utils import load_saved_path, save_path, should_check_for_auto_update, parse_yt_dlp_error
+from src.core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update
from src.core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
AboutDialog,
@@ -45,35 +44,24 @@ from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__
from src.gui.ytsage_gui_format_table import FormatTableMixin
from src.gui.ytsage_gui_video_info import VideoInfoMixin
from src.utils.ytsage_constants import ICON_PATH, SOUND_PATH, SUBPROCESS_CREATIONFLAGS
+from src.utils.ytsage_logger import logger
try:
import yt_dlp
- from yt_dlp.utils import ExtractorError, DownloadError
+ from yt_dlp.utils import DownloadError, ExtractorError
YT_DLP_AVAILABLE = True
except ImportError:
YT_DLP_AVAILABLE = False
-try:
- import pyglet
-
- PYGLET_AVAILABLE = True
-except ImportError:
- PYGLET_AVAILABLE = False
-
class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins
def __init__(self) -> None:
super().__init__()
- # Initialize logger for this class
- self.logger = logger.bind(module="YTSageApp")
-
# Log startup warnings for missing dependencies
if not YT_DLP_AVAILABLE:
- self.logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
- if not PYGLET_AVAILABLE:
- self.logger.warning("pyglet not available, audio notifications disabled")
+ logger.warning("yt-dlp not available at startup, will be downloaded at runtime")
# Check for FFmpeg before proceeding
if not check_ffmpeg():
@@ -84,9 +72,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if ytdlp_path == "yt-dlp": # Not found in app dir or PATH
self.show_ytdlp_setup_dialog()
else:
- self.logger.info(f"Using yt-dlp from: {ytdlp_path}")
+ logger.info(f"Using yt-dlp from: {ytdlp_path}")
- self.version = "4.8.0b"
+ self.version = "4.8.3"
self.check_for_updates()
# Check for auto-updates if enabled
@@ -95,9 +83,9 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
load_saved_path(self)
# Load custom icon
if ICON_PATH.exists():
- self.setWindowIcon(QIcon(ICON_PATH.as_posix()))
+ self.setWindowIcon(QIcon(str(ICON_PATH)))
else:
- self.logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
+ logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
self.signals = SignalManager()
self.download_paused = False
@@ -120,8 +108,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.video_url = ""
self.selected_subtitles = [] # Initialize selected subtitles list
# Initialize cookie settings - ensure they start clean
- self.cookie_file_path = None
- self.browser_cookies_option = None
+ self.cookie_file_path = None
+ self.browser_cookies_option = None
self.speed_limit_value = None # Store speed limit value
self.speed_limit_unit_index = 0 # Store speed limit unit index (0: KB/s, 1: MB/s)
self.download_section = None
@@ -305,53 +293,23 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Initialize UI state based on current mode
self.handle_mode_change()
- # Initialize pyglet for sound notifications
- self.init_sound()
-
- def init_sound(self) -> None:
- """Initialize pyglet for sound notifications"""
- try:
- if PYGLET_AVAILABLE:
- self.sound_enabled = True
-
- # sound_path logic moved to src\utils\ytsage_constants.py
- self.notification_sound_path = SOUND_PATH
-
- # Check if the notification sound file exists
- if not self.notification_sound_path.exists():
- self.logger.warning(f"Notification sound file not found at: {self.notification_sound_path}")
- self.sound_enabled = False
- else:
- self.logger.info(f"Notification sound loaded from: {self.notification_sound_path}")
- else:
- self.sound_enabled = False
- self.logger.info("Sound notifications disabled - pyglet not available")
-
- except Exception as e:
- self.logger.error(f"Error initializing sound: {e}")
- self.sound_enabled = False
+ # Init_sound method is removed, serve no purpose.
def play_notification_sound(self) -> None:
- """Play notification sound in a separate thread to avoid blocking the UI"""
- if not self.sound_enabled:
- return
+ """Play notification sound asynchronously (non-blocking)."""
+ try:
+ # Check if the notification sound file exists
+ if not SOUND_PATH.exists():
+ logger.warning(f"Notification sound file not found at: {SOUND_PATH}")
+ return
- def play_sound() -> None:
- try:
- if PYGLET_AVAILABLE:
- # Play the sound using pyglet
- sound = pyglet.media.load(str(self.notification_sound_path))
- sound.play()
-
- except Exception as e:
- self.logger.error(f"Error playing notification sound: {e}")
-
- # Play sound in a separate thread to avoid blocking the UI
- sound_thread = threading.Thread(target=play_sound)
- sound_thread.daemon = True
- sound_thread.start()
-
- # Removed load_saved_path and save_path methods since their functionality is now handled directly by ytsage_utils
+ # Play the sound using pyglet
+ # no need for the thread, as .play() is async
+ sound = pyglet.media.load(str(SOUND_PATH), streaming=False)
+ sound.play()
+ logger.debug("Notification sound played")
+ except Exception as e:
+ logger.exception(f"Error playing notification sound: {e}")
def init_ui(self) -> None:
self.setWindowTitle(f"YTSage v{self.version}")
@@ -731,6 +689,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Initial extraction with basic options - suppress warnings here too
ydl_opts = {
+ "logger": logger,
"quiet": False,
"no_warnings": True, # <-- Suppress warnings for initial check
"extract_flat": True,
@@ -745,30 +704,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path:
ydl_opts["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
- ydl_opts["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0],
- self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None)
+ ydl_opts["cookiesfrombrowser"] = (
+ self.browser_cookies_option.split(":")[0],
+ self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
+ )
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
basic_info = ydl.extract_info(url, download=False)
if not basic_info:
- # This case usually means the URL is invalid or not found
- raise Exception("Invalid URL or video not found. Please check the link and try again.")
- except (ExtractorError, DownloadError) as e:
- # This is a yt-dlp specific error, use the original message
- user_friendly_error = parse_yt_dlp_error(str(e))
- raise Exception(user_friendly_error)
+ logger.error("Could not extract basic video information")
+ self.signals.update_status.emit(
+ "Error: Could not extract basic video information. Please check your link."
+ )
+ # Hide playlist UI on error
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
except Exception as e:
- # This is our own exception or other unexpected error
- self.logger.error(f"First extraction failed: {str(e)}")
- self.logger.error(f"Exception type: {type(e)}")
+ logger.exception(f"First extraction failed: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
user_friendly_error = parse_yt_dlp_error(str(e))
- raise Exception(user_friendly_error)
+ self.signals.update_status.emit(user_friendly_error)
+ return
self.signals.update_status.emit("Analyzing (30%)... Extracting detailed info")
# Configure options for detailed extraction (keep other options)
# Add no_warnings here as well, as this is where detailed info is fetched
ydl_opts_detail = {
+ "logger": logger,
"extract_flat": False,
"format": None,
"writesubtitles": True,
@@ -785,8 +750,10 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
if self.cookie_file_path:
ydl_opts_detail["cookiefile"] = str(self.cookie_file_path)
elif self.browser_cookies_option:
- ydl_opts_detail["cookiesfrombrowser"] = (self.browser_cookies_option.split(':')[0],
- self.browser_cookies_option.split(':')[1] if ':' in self.browser_cookies_option else None)
+ ydl_opts_detail["cookiesfrombrowser"] = (
+ self.browser_cookies_option.split(":")[0],
+ self.browser_cookies_option.split(":")[1] if ":" in self.browser_cookies_option else None,
+ )
# Use a separate options dict for the detailed extraction
with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail:
@@ -800,25 +767,30 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Ensure there are entries before proceeding
if not self.playlist_entries:
- raise Exception("Playlist contains no valid videos.")
-
+ logger.error("Playlist contains no valid videos.")
+ self.signals.update_status.emit("Error: Playlist contains no valid videos.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
# Extract detailed info for the FIRST video in the playlist
# This provides formats/subs for the UI, assuming consistency
first_video_url = self.playlist_entries[0].get("url")
if not first_video_url:
- raise Exception("Could not get URL for the first playlist video.")
+ logger.error("Could not get URL for the first playlist video.")
+ self.signals.update_status.emit("Error: Could not get URL for the first playlist video.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
try:
# Use the ydl_detail instance with no_warnings
self.video_info = ydl_detail.extract_info(first_video_url, download=False)
- except (ExtractorError, DownloadError) as first_video_error:
- # Use error parser for yt-dlp specific playlist video errors
- user_friendly_error = parse_yt_dlp_error(str(first_video_error))
- raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}")
- except Exception as first_video_error:
- # Use error parser for other playlist video errors too
- user_friendly_error = parse_yt_dlp_error(str(first_video_error))
- raise Exception(f"Failed to extract info for the first playlist video: {user_friendly_error}")
-
+ except Exception as e:
+ logger.exception(f"Failed to extract info for the first playlist video: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ user_friendly_error = parse_yt_dlp_error(str(e))
+ self.signals.update_status.emit(user_friendly_error)
+ return
# Update playlist info label text (remains the same)
playlist_text = (
f"Playlist: {basic_info.get('title', 'Unknown Playlist')} | " f"{len(self.playlist_entries)} videos"
@@ -847,8 +819,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
- self.logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
- raise Exception("No format information available")
+ logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}")
+ self.signals.update_status.emit("Error: No format information available.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
self.signals.update_status.emit("Analyzing (60%)... Processing formats")
self.all_formats = self.video_info["formats"]
@@ -861,7 +836,6 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Try to get thumbnail from playlist info first
# Fallback to video thumbnail if playlist thumbnail not found or not a playlist
thumbnail_url = (self.playlist_info or {}).get("thumbnail") or (self.video_info or {}).get("thumbnail")
-
self.download_thumbnail(thumbnail_url)
# Save thumbnail if enabled - use the stored VIDEO URL
@@ -901,22 +875,19 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.video_button.setChecked(True)
self.audio_button.setChecked(False)
self.filter_formats()
-
self.signals.update_status.emit("Analysis complete!")
- except (ExtractorError, DownloadError) as e:
- logger.error(f"yt-dlp detailed extraction failed: {e}", exc_info=True)
- # Use the error parser for yt-dlp specific errors
- user_friendly_error = parse_yt_dlp_error(str(e))
- raise Exception(user_friendly_error)
except Exception as e:
- logger.error(f"Detailed extraction failed: {e}", exc_info=True)
+ logger.exception(f"Detailed extraction failed: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
# Use the error parser for other extraction errors too
user_friendly_error = parse_yt_dlp_error(str(e))
- raise Exception(user_friendly_error)
+ self.signals.update_status.emit(user_friendly_error)
+ return
except Exception as e:
- self.logger.error(f"Error in analysis: {e}", exc_info=True)
+ logger.exception(f"Error in analysis: {e}")
self.signals.update_status.emit(f"Error: {e}")
# Ensure playlist UI is hidden on error too
# update signal method from QMetaObject.invokeMethod to signals
@@ -944,7 +915,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.last_path = new_path
save_path(self, self.last_path) # Save the updated path
path_changed = True
- self.logger.info(f"Download path updated to: {self.last_path}")
+ logger.info(f"Download path updated to: {self.last_path}")
# Update Speed Limit
new_limit_value = dialog.get_selected_speed_limit()
@@ -954,7 +925,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.speed_limit_value = new_limit_value
self.speed_limit_unit_index = new_unit_index
limit_changed = True
- self.logger.info(
+ logger.info(
f"Speed limit updated to: {self.speed_limit_value} {['KB/s', 'MB/s'][self.speed_limit_unit_index] if self.speed_limit_value else 'None'}"
)
@@ -1030,7 +1001,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
self.download_thumbnail_file(url, path)
except Exception as e:
- self.logger.warning(f"Thumbnail download failed: {e}")
+ logger.warning(f"Thumbnail download failed: {e}", exc_info=True)
# Optionally inform the user, but don't stop the main download
# Create download thread with resolution in output template
@@ -1116,7 +1087,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
int_value = int(value)
self.progress_bar.setValue(int_value)
except Exception as e:
- self.logger.error(f"Progress bar update error: {str(e)}")
+ logger.exception(f"Progress bar update error: {e}")
def toggle_pause(self) -> None:
if self.current_download:
@@ -1145,7 +1116,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
changelog = latest_release.get("body", "No changelog available.") # Get changelog body
self.show_update_dialog(latest_version, latest_release["html_url"], changelog) # Pass changelog
except Exception as e:
- self.logger.error(f"Failed to check for updates: {str(e)}", exc_info=True)
+ logger.exception(f"Failed to check for updates: {e}")
def show_update_dialog(self, latest_version, release_url, changelog) -> None: # Added changelog parameter
msg = QDialog(self)
@@ -1161,7 +1132,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Fallback to icon file
# icon_path logic moved to src\utils\ytsage_constants.py
if ICON_PATH.exists():
- msg.setWindowIcon(QIcon(ICON_PATH.as_posix()))
+ msg.setWindowIcon(QIcon(str(ICON_PATH)))
except Exception:
pass
@@ -1224,7 +1195,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
)
changelog_text.setHtml(html_changelog)
except Exception as e:
- self.logger.warning(f"Error converting changelog markdown to HTML: {e}")
+ logger.warning(f"Error converting changelog markdown to HTML: {e}", exc_info=True)
changelog_text.setPlainText(changelog) # Fallback to plain text
changelog_text.setStyleSheet(
@@ -1339,14 +1310,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
# Check if auto-update should be performed
if should_check_for_auto_update():
- self.logger.info("Performing auto-update check for yt-dlp...")
+ logger.info("Performing auto-update check for yt-dlp...")
# Perform the auto-update in a non-blocking way
# We don't want to block the UI startup for this
- from PySide6.QtCore import QTimer
-
QTimer.singleShot(2000, self._perform_auto_update) # Delay 2 seconds after startup
except Exception as e:
- self.logger.error(f"Error in auto-update check: {e}", exc_info=True)
+ logger.exception(f"Error in auto-update check: {e}")
def _perform_auto_update(self) -> None:
"""Actually perform the auto-update check and update if needed in a background thread."""
@@ -1357,14 +1326,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.auto_update_thread.update_finished.connect(self._on_auto_update_finished)
self.auto_update_thread.start()
except Exception as e:
- self.logger.error(f"Error starting auto-update thread: {e}", exc_info=True)
+ logger.exception(f"Error starting auto-update thread: {e}")
def _on_auto_update_finished(self, success, message) -> None:
"""Handle auto-update completion."""
if success:
- self.logger.info(f"Auto-update completed successfully: {message}")
+ logger.info(f"Auto-update completed successfully: {message}")
else:
- self.logger.warning(f"Auto-update completed with issues: {message}")
+ logger.warning(f"Auto-update completed with issues: {message}")
# Clean up the thread reference and ensure it's properly finished
if hasattr(self, "auto_update_thread"):
@@ -1382,26 +1351,26 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
# Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
- self.logger.info("Stopping auto-update thread...")
+ logger.info("Stopping auto-update thread...")
self.auto_update_thread.quit()
if not self.auto_update_thread.wait(3000): # Wait up to 3 seconds for graceful shutdown
- self.logger.warning("Force terminating auto-update thread...")
+ logger.warning("Force terminating auto-update thread...")
self.auto_update_thread.terminate()
self.auto_update_thread.wait(1000) # Wait for termination
# Cancel any running downloads
if self.current_download and self.current_download.isRunning():
- self.logger.info("Canceling running download...")
+ logger.info("Canceling running download...")
self.current_download.cancel()
if not self.current_download.wait(3000): # Wait up to 3 seconds for graceful shutdown
- self.logger.warning("Force terminating download thread...")
+ logger.warning("Force terminating download thread...")
self.current_download.terminate()
self.current_download.wait(1000) # Wait for termination
- self.logger.info("Application closing...")
+ logger.info("Application closing...")
event.accept()
except Exception as e:
- self.logger.error(f"Error during application close: {e}", exc_info=True)
+ logger.exception(f"Error during application close: {e}")
event.accept() # Accept the close event anyway
def show_custom_options(self) -> None:
@@ -1410,14 +1379,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
-
+
# Clear both first to avoid conflicts
self.cookie_file_path = None
self.browser_cookies_option = None
-
+
if cookie_path:
self.cookie_file_path = cookie_path
- self.logger.info(f"Selected cookie file: {self.cookie_file_path}")
+ logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
@@ -1425,7 +1394,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
)
elif browser_cookies:
self.browser_cookies_option = browser_cookies
- self.logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
+ logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
@@ -1499,32 +1468,32 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# --- Add Toggle Methods Here ---
def toggle_save_thumbnail(self, state) -> None:
- self.logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
+ logger.debug(f"Raw thumbnail state received: {state}") # Debug: Print raw state
self.save_thumbnail = bool(state == 2) # Compare state directly with 2 (Checked state)
- self.logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
+ logger.debug(f"Save thumbnail toggled: {self.save_thumbnail}")
def toggle_save_description(self, state) -> None:
- self.logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
+ logger.debug(f"Raw description state received: {state}") # Debug: Print raw state
self.save_description = bool(state == 2) # Compare state directly with 2 (Checked state)
- self.logger.debug(f"Save description toggled: {self.save_description}")
+ logger.debug(f"Save description toggled: {self.save_description}")
def toggle_embed_chapters(self, state) -> None:
- self.logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
+ logger.debug(f"Raw chapters state received: {state}") # Debug: Print raw state
self.embed_chapters = bool(state == 2) # Compare state directly with 2 (Checked state)
- self.logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
+ logger.debug(f"Embed chapters toggled: {self.embed_chapters}")
# --- End Toggle Methods ---
def open_playlist_selection_dialog(self) -> None:
if not self.is_playlist or not self.playlist_entries:
- self.logger.info("No playlist data available to select from.")
+ logger.info("No playlist data available to select from.")
return
dialog = PlaylistSelectionDialog(self.playlist_entries, self.selected_playlist_items, self)
if dialog.exec():
self.selected_playlist_items = dialog.get_selected_items_string()
- self.logger.info(f"Playlist items selected: {self.selected_playlist_items}")
+ logger.info(f"Playlist items selected: {self.selected_playlist_items}")
# Update button text (this call is safe as it happens in the main thread after dialog closes)
if self.selected_playlist_items is None:
@@ -1612,11 +1581,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Handle cookies
cookie_path = dialog.get_cookie_file_path()
browser_cookies = dialog.get_browser_cookies_option()
-
+
if cookie_path:
self.cookie_file_path = cookie_path
self.browser_cookies_option = None # Clear browser cookies if file is used
- self.logger.info(f"Selected cookie file: {self.cookie_file_path}")
+ logger.info(f"Selected cookie file: {self.cookie_file_path}")
QMessageBox.information(
self,
"Cookie File Selected",
@@ -1625,7 +1594,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
elif browser_cookies:
self.browser_cookies_option = browser_cookies
self.cookie_file_path = None # Clear file cookies if browser is used
- self.logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
+ logger.info(f"Selected browser cookies: {self.browser_cookies_option}")
QMessageBox.information(
self,
"Browser Cookies Selected",
@@ -1717,7 +1686,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
try:
yt_dlp_path = get_yt_dlp_path()
if not yt_dlp_path:
- raise Exception("yt-dlp executable not found. Please install yt-dlp first.")
+ logger.error("yt-dlp executable not found. Please install yt-dlp first.")
+ self.signals.update_status.emit("Error: yt-dlp executable not found. Please install yt-dlp first.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
self.signals.update_status.emit("Analyzing (30%)... Extracting info with yt-dlp executable")
@@ -1740,16 +1713,29 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, creationflags=SUBPROCESS_CREATIONFLAGS)
if result.returncode != 0:
- raise Exception(f"yt-dlp failed: {result.stderr}")
+ logger.error(f"yt-dlp failed: {result.stderr}")
+ self.signals.update_status.emit(f"Error: yt-dlp failed: {result.stderr}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
- # Parse JSON output - yt-dlp outputs one JSON object per line for playlists
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
if not json_lines:
- raise Exception("No data returned from yt-dlp")
+ logger.error("No data returned from yt-dlp")
+ self.signals.update_status.emit("Error: No data returned from yt-dlp")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
- # Parse first JSON object to determine if it's a playlist
- first_info = json.loads(json_lines[0])
+ try:
+ first_info = json.loads(json_lines[0])
+ except json.JSONDecodeError as e:
+ logger.error(f"Failed to parse yt-dlp output: {e}")
+ self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
self.signals.update_status.emit("Analyzing (60%)... Processing data")
@@ -1770,7 +1756,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
continue
if not self.playlist_entries:
- raise Exception("Playlist contains no valid videos.")
+ logger.error("Playlist contains no valid videos.")
+ self.signals.update_status.emit("Error: Playlist contains no valid videos.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
# Use first video for format information
self.video_info = self.playlist_entries[0]
@@ -1806,7 +1796,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
# Verify we have format information
if not self.video_info or "formats" not in self.video_info:
- raise Exception("No format information available")
+ logger.error("No format information available")
+ self.signals.update_status.emit("Error: No format information available.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
+ return
self.signals.update_status.emit("Analyzing (75%)... Processing formats")
self.all_formats = self.video_info["formats"]
@@ -1845,8 +1839,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from
self.signals.update_status.emit("Analysis complete!")
except subprocess.TimeoutExpired:
- raise Exception("Analysis timed out. Please try again.")
+ logger.error("Analysis timed out. Please try again.")
+ self.signals.update_status.emit("Error: Analysis timed out. Please try again.")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
except json.JSONDecodeError as e:
- raise Exception(f"Failed to parse yt-dlp output: {str(e)}")
+ logger.error(f"Failed to parse yt-dlp output: {e}")
+ self.signals.update_status.emit(f"Error: Failed to parse yt-dlp output: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
except Exception as e:
- raise Exception(f"Analysis failed: {str(e)}")
+ logger.error(f"Analysis failed: {e}")
+ self.signals.update_status.emit(f"Error: Analysis failed: {e}")
+ self.signals.playlist_info_label_visible.emit(False)
+ self.signals.playlist_select_btn_visible.emit(False)
diff --git a/src/gui/ytsage_gui_video_info.py b/src/gui/ytsage_gui_video_info.py
index 362524d..56353ca 100644
--- a/src/gui/ytsage_gui_video_info.py
+++ b/src/gui/ytsage_gui_video_info.py
@@ -2,22 +2,28 @@ import re
from datetime import datetime
from io import BytesIO
from pathlib import Path
+from typing import TYPE_CHECKING, cast
import requests
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
-from PySide6.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QPushButton, QVBoxLayout, QWidget
+from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
-from src.core.ytsage_logging import logger
from src.gui.ytsage_gui_dialogs import ( # use of src\gui\ytsage_gui_dialogs\__init__.py
SponsorBlockCategoryDialog,
SubtitleSelectionDialog,
)
+from src.utils.ytsage_logger import logger
+
+if TYPE_CHECKING:
+ from src.gui.ytsage_gui_main import YTSageApp
class VideoInfoMixin:
def setup_video_info_section(self) -> QHBoxLayout:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
# Create a horizontal layout for thumbnail and video info
media_info_layout = QHBoxLayout()
media_info_layout.setSpacing(15)
@@ -184,6 +190,8 @@ class VideoInfoMixin:
return media_info_layout
def setup_playlist_info_section(self) -> QLabel:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
self.playlist_info_label = QLabel()
self.playlist_info_label.setVisible(False)
self.playlist_info_label.setStyleSheet(
@@ -205,6 +213,8 @@ class VideoInfoMixin:
return self.playlist_info_label
def update_video_info(self, info) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
if hasattr(self, "is_playlist") and self.is_playlist:
# Playlist Mode: Show playlist title and video count
self.title_label.setText(self.playlist_info.get("title", "Unknown Playlist"))
@@ -260,6 +270,8 @@ class VideoInfoMixin:
self.duration_label.setText(f"Duration: {duration_str}")
def open_subtitle_dialog(self) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
if not hasattr(self, "available_subtitles") or not hasattr(self, "available_automatic_subtitles"):
logger.warning("Subtitle info not loaded yet.")
return
@@ -274,16 +286,8 @@ class VideoInfoMixin:
self, # Parent for the dialog
)
- # Access the main application window (parent of the mixin's widget)
- # to find the merge checkbox
- main_window = self # In this context, self should be the YTSageApp instance
- if not isinstance(main_window, QMainWindow):
- # If the structure is different, this might need adjustment
- # Maybe self.parentWidget() or similar depending on how Mixin is used
- logger.warning("Cannot find main window to access merge checkbox.")
- merge_checkbox = None
- else:
- merge_checkbox = getattr(main_window, "merge_subs_checkbox", None)
+ # removed extra logic for mapping to main_windows
+ merge_checkbox = getattr(self, "merge_subs_checkbox", None)
if dialog.exec(): # If user clicks OK
self.selected_subtitles = dialog.get_selected_subtitles()
@@ -296,7 +300,7 @@ class VideoInfoMixin:
# Enable/disable the merge checkbox in the parent window
if merge_checkbox:
# Only enable merge checkbox if we're not in Audio Only mode
- is_audio_only = hasattr(main_window, "audio_button") and main_window.audio_button.isChecked()
+ is_audio_only = hasattr(self, "audio_button") and self.audio_button.isChecked()
# In audio-only mode, we still allow subtitle selection but not merging
should_enable = count > 0 and not is_audio_only
merge_checkbox.setEnabled(should_enable)
@@ -310,6 +314,8 @@ class VideoInfoMixin:
def open_sponsorblock_dialog(self) -> None:
"""Open the SponsorBlock category selection dialog."""
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
# Initialize selected categories if not exists or empty (first time opening)
if not hasattr(self, "selected_sponsorblock_categories") or not self.selected_sponsorblock_categories:
# Use None to let the dialog set its own defaults
@@ -326,6 +332,8 @@ class VideoInfoMixin:
def _update_sponsorblock_display(self) -> None:
"""Update the SponsorBlock button and label to reflect current selection."""
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
if not hasattr(self, "selected_sponsorblock_categories"):
self.selected_sponsorblock_categories = []
@@ -347,6 +355,8 @@ class VideoInfoMixin:
self.sponsorblock_select_btn.style().polish(self.sponsorblock_select_btn)
def download_thumbnail(self, url) -> None:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
+
try:
# Store both thumbnail URL and video URL
self.thumbnail_url = url
@@ -364,7 +374,10 @@ class VideoInfoMixin:
pixmap.loadFromData(img_byte_arr.getvalue())
self.thumbnail_label.setPixmap(pixmap)
except Exception as e:
- logger.error(f"Error loading thumbnail: {str(e)}")
+ logger.exception(f"Error loading thumbnail: {e}")
+
+ def download_thumbnail_file(self, video_url, path) -> bool:
+ self = cast("YTSageApp", self) # for autocompletion and type inference.
def download_thumbnail_file(self, video_url, path) -> bool:
if not self.save_thumbnail:
@@ -373,10 +386,11 @@ class VideoInfoMixin:
try:
# Import yt_dlp locally to avoid import errors when yt-dlp is not installed
from yt_dlp import YoutubeDL
-
+
logger.debug(f"Attempting to save thumbnail for URL: {video_url}")
ydl_opts = {
+ "logger": logger,
"quiet": True,
"skip_download": True,
"force_generic_extractor": False,
@@ -389,7 +403,7 @@ class VideoInfoMixin:
thumbnails = info.get("thumbnails", [])
if not thumbnails:
- raise ValueError("No thumbnails available")
+ logger.info("No thumbnails available")
thumbnail_url = max(
thumbnails,
@@ -397,7 +411,7 @@ class VideoInfoMixin:
).get("url")
if not thumbnail_url:
- raise ValueError("Failed to extract thumbnail URL")
+ logger.info("Failed to extract thumbnail URL")
# Download using requests
response = requests.get(thumbnail_url)
@@ -418,8 +432,8 @@ class VideoInfoMixin:
return True
except Exception as e:
- error_msg = f"❌ Thumbnail error: {str(e)}"
- logger.error(f"Thumbnail Save Error: {str(e)}")
+ error_msg = f"❌ Thumbnail error: {e}"
+ logger.exception(f"Thumbnail Save Error: {e}")
self.signals.update_status.emit(error_msg)
return False
diff --git a/src/utils/ytsage_config_manager.py b/src/utils/ytsage_config_manager.py
new file mode 100644
index 0000000..062d358
--- /dev/null
+++ b/src/utils/ytsage_config_manager.py
@@ -0,0 +1,202 @@
+"""
+Config Manager Module
+=====================
+
+This module provides **thread-safe** centralized management for application
+configuration in YTSage. It handles reading, writing, and managing settings
+stored in a JSON file, with support for nested keys via dot notation.
+
+Thread safety is ensured using a reentrant lock (`RLock`), so multiple threads
+can safely access or modify settings concurrently.
+
+Features
+--------
+- Thread-safe operations for getting, setting, and deleting configuration values.
+- Loads settings from a JSON config file (`APP_CONFIG_FILE`).
+- Creates the config file with default values if missing or corrupt.
+- Retrieves, sets, and deletes settings using dot-separated keys.
+- Provides safe error handling with logging instead of raising exceptions.
+- Persists updates back to disk automatically.
+
+Usage
+-----
+from src.utils.ytsage_config_manager import ConfigManager
+
+# Load settings (auto-loads if not already loaded)
+download_path = ConfigManager.get("download_path")
+
+# Update a value
+ConfigManager.set("download_path", "D:/Downloads")
+
+# Retrieve nested value
+last_check = ConfigManager.get("cached_versions.ytdlp.last_check")
+
+# Delete a key
+ConfigManager.delete("cached_versions.ffmpeg.path")
+
+Design Notes
+------------
+- Settings are stored in `ConfigManager.settings` (a dict).
+- Default values are defined in `ConfigManager.default_config`.
+- All modifications trigger a save (`_save`) to keep JSON in sync.
+- Logs actions and errors using the app's central logger.
+- Uses `RLock` to allow safe concurrent access from multiple threads.
+
+Exceptions
+----------
+- Any issues during file I/O (permissions, disk errors, JSON corruption)
+ are caught and logged. The application continues running with defaults
+ when possible.
+"""
+
+import json
+import threading
+from typing import Any
+
+from src.utils.ytsage_constants import APP_CONFIG_FILE, USER_HOME_DIR
+from src.utils.ytsage_logger import logger
+
+
+class ConfigManager:
+ """
+ Thread-safe configuration manager for YTSage.
+
+ Provides methods to load, save, get, set, and delete settings stored in a JSON file.
+ Supports nested keys via dot notation and automatically persists changes.
+ """
+
+ _lock = threading.RLock()
+ _config_file = APP_CONFIG_FILE
+ _settings: dict[str, Any] = {}
+ _default_config = {
+ "download_path": str(USER_HOME_DIR / "Downloads"),
+ "speed_limit_value": None,
+ "speed_limit_unit_index": 0,
+ "cookie_file_path": None,
+ "last_used_cookie_file": None,
+ "auto_update_ytdlp": True,
+ "auto_update_frequency": "daily",
+ "last_update_check": 0,
+ "cached_versions": {
+ "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
+ "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
+ },
+ }
+
+ @classmethod
+ def _load(cls) -> None:
+ """
+ Loads configuration settings from a JSON file if it exists and is valid.
+ If the file is missing or corrupt, loads default settings and creates or overwrites the config file as needed.
+ Logs actions and errors during the process.
+ """
+ with cls._lock:
+ if cls._config_file.exists():
+ try:
+ with open(cls._config_file, "r", encoding="utf-8") as f:
+ cls._settings = json.load(f)
+ logger.info("Config loaded from file.")
+ except json.JSONDecodeError:
+ cls._settings = cls._default_config.copy()
+ logger.warning("Config file corrupt, loaded defaults.")
+ else:
+ cls._settings = cls._default_config.copy()
+ cls._save()
+ logger.info("Config file not found, created default config.")
+
+ @classmethod
+ def _save(cls) -> None:
+ """
+ Save current settings to JSON file.
+
+ Note:
+ May raise exceptions if the file cannot be written due to permission issues,
+ disk errors, or other I/O problems.
+ """
+ with cls._lock:
+ try:
+ with open(cls._config_file, "w", encoding="utf-8") as f:
+ json.dump(cls._settings, f, indent=4)
+ logger.debug("Config saved to file.")
+ except (OSError, PermissionError) as e:
+ logger.exception(f"Failed to save config: {e}")
+ except Exception as e:
+ logger.exception(f"Unexpected error while saving config: {e}")
+
+ @classmethod
+ def get(cls, key: str) -> Any:
+ """
+ Retrieve a configuration value using a dotted key notation.
+ Args:
+ key (str): The dotted key string representing the path to the desired setting (e.g., "database.host").
+ Any: The value associated with the given key, or None if the key does not exist.
+ Notes:
+ - If the configuration settings are not loaded, this method will load them before attempting retrieval.
+ - If any part of the dotted key path is missing, None is returned and a debug message is logged.
+ """
+ with cls._lock:
+ if not cls._settings:
+ cls._load()
+ parts = key.split(".")
+ value = cls._settings
+ for part in parts:
+ if isinstance(value, dict) and part in value:
+ value = value[part]
+ else:
+ logger.debug(f"Config key '{key}' not found.")
+ return None
+ return value
+
+ @classmethod
+ def set(cls, key: str, value: Any) -> None:
+ """
+ Sets a configuration value for a given key.
+ If the configuration settings are not loaded, loads them first.
+ Supports nested keys using dot notation (e.g., "database.host").
+ Updates the configuration dictionary with the provided value,
+ saves the updated settings, and logs the change.
+ Args:
+ key (str): The configuration key, possibly nested using dots.
+ value (Any): The value to set for the specified key.
+ Returns:
+ None
+ """
+ with cls._lock:
+ if not cls._settings:
+ cls._load()
+ parts = key.split(".")
+ d = cls._settings
+ for part in parts[:-1]:
+ d = d.setdefault(part, {})
+ d[parts[-1]] = value
+ cls._save()
+ logger.info(f"Config key '{key}' set to '{value}'.")
+
+ @classmethod
+ def delete(cls, key: str) -> None:
+ """
+ Deletes a configuration key from the settings.
+ If the key is nested (dot-separated), traverses the settings dictionary accordingly.
+ If the key exists, removes it and saves the updated settings.
+ Logs the deletion or if the key was not found.
+ Args:
+ key (str): The dot-separated configuration key to delete.
+ Returns:
+ None
+ """
+ with cls._lock:
+ if not cls._settings:
+ cls._load()
+ parts = key.split(".")
+ d = cls._settings
+ for part in parts[:-1]:
+ if part not in d:
+ logger.debug(f"Config key '{key}' not found for deletion.")
+ return
+ d = d[part]
+ if parts[-1] in d:
+ d.pop(parts[-1], None)
+ cls._save()
+ logger.info(f"Config key '{key}' deleted.")
+ else:
+ logger.debug(f"Config key '{key}' not found for deletion.")
diff --git a/src/utils/ytsage_constants.py b/src/utils/ytsage_constants.py
index 62a0d7e..882584d 100644
--- a/src/utils/ytsage_constants.py
+++ b/src/utils/ytsage_constants.py
@@ -59,14 +59,18 @@ SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
+IS_FROZEN = getattr(sys, "frozen", False)
USER_HOME_DIR: Path = Path.home()
# OS Specific Constants
if OS_NAME == "Windows":
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
- # APP_PATH will be from system environment path or fallback to Path.home()
- APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage"
+ if IS_FROZEN:
+ APP_DIR: Path = Path(sys.executable).parent
+ else:
+ # APP_PATH will be from system environment path or fallback to Path.home()
+ APP_DIR: Path = Path(os.environ.get("LOCALAPPDATA", USER_HOME_DIR / "AppData" / "Local")) / "YTSage"
APP_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -74,9 +78,6 @@ if OS_NAME == "Windows":
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp.exe"
-
- # Documentation URLs
- YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options"
SUBPROCESS_CREATIONFLAGS: int = subprocess.CREATE_NO_WINDOW
@@ -84,7 +85,10 @@ elif OS_NAME == "Darwin": # macOS
_mac_version = platform.mac_ver()[0]
OS_FULL_NAME: str = f"macOS {_mac_version}" if _mac_version else "macOS"
- APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage"
+ if IS_FROZEN:
+ APP_DIR: Path = Path(sys.executable).parent
+ else:
+ APP_DIR: Path = USER_HOME_DIR / "Library" / "Application Support" / "YTSage"
APP_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -92,9 +96,6 @@ elif OS_NAME == "Darwin": # macOS
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
-
- # Documentation URLs
- YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options"
SUBPROCESS_CREATIONFLAGS: int = 0
@@ -102,7 +103,10 @@ elif OS_NAME == "Darwin": # macOS
else: # Linux and other UNIX-like
OS_FULL_NAME: str = f"{OS_NAME} {platform.release()}"
- APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage"
+ if IS_FROZEN:
+ APP_DIR: Path = Path(sys.executable).parent
+ else:
+ APP_DIR: Path = USER_HOME_DIR / ".local" / "share" / "YTSage"
APP_BIN_DIR: Path = APP_DIR / "bin"
APP_DATA_DIR: Path = APP_DIR / "data"
APP_LOG_DIR: Path = APP_DIR / "logs"
@@ -110,12 +114,11 @@ else: # Linux and other UNIX-like
YTDLP_DOWNLOAD_URL: str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
YTDLP_APP_BIN_PATH: Path = APP_BIN_DIR / "yt-dlp"
-
- # Documentation URLs
- YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options"
SUBPROCESS_CREATIONFLAGS: int = 0
+# Documentation URLs
+YTDLP_DOCS_URL: str = "https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#usage-and-options"
# ffmpeg download links
FFMPEG_7Z_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.7z"
@@ -124,6 +127,7 @@ FFMPEG_ZIP_DOWNLOAD_URL = "https://github.com/GyanD/codexffmpeg/releases/downloa
if __name__ == "__main__":
# If this file is run directly, print directory information; if imported, create the necessary directories for the application.
+ # for debug, to check os specific variable which can be different based on os.
info = {
"OS_NAME": OS_NAME,
"OS_FULL_NAME": OS_FULL_NAME,
@@ -135,7 +139,6 @@ if __name__ == "__main__":
"APP_CONFIG_FILE": str(APP_CONFIG_FILE),
"YTDLP_DOWNLOAD_URL": YTDLP_DOWNLOAD_URL,
"YTDLP_APP_BIN_PATH": YTDLP_APP_BIN_PATH,
- "YTDLP_DOCS_URL": YTDLP_DOCS_URL,
"SUBPROCESS_CREATIONFLAGS": SUBPROCESS_CREATIONFLAGS,
}
for key, value in info.items():
diff --git a/src/utils/ytsage_logger.py b/src/utils/ytsage_logger.py
new file mode 100644
index 0000000..a066454
--- /dev/null
+++ b/src/utils/ytsage_logger.py
@@ -0,0 +1,56 @@
+"""
+YTSage centralized logging with loguru.
+
+- This module provides centralized logging configuration for the entire YTSage application.
+- Two log files: ytsage.log (all logs) & ytsage_error.log (errors only).
+"""
+
+import sys
+
+from loguru import logger
+
+from src.utils.ytsage_constants import APP_LOG_DIR, IS_FROZEN
+
+# Separate configs for each handler
+CONSOLE_CONFIG = {
+ "sink": sys.stdout if sys.stdout else sys.stderr,
+ "level": "INFO",
+ "colorize": True,
+ "enqueue": True,
+}
+
+ALL_LOGS_CONFIG = {
+ "sink": APP_LOG_DIR / "ytsage.log",
+ "level": "DEBUG",
+ "rotation": "10 MB",
+ "retention": "14 days",
+ "compression": "zip",
+ "enqueue": True,
+}
+
+ERROR_LOGS_CONFIG = {
+ "sink": APP_LOG_DIR / "ytsage_error.log",
+ "level": "ERROR",
+ "rotation": "5 MB",
+ "retention": "30 days",
+ "compression": "zip",
+ "enqueue": True,
+}
+
+
+# Logger initialization
+def init_logger() -> None:
+ """Configure loguru logger using separate configs for each handler."""
+ logger.remove() # Remove default loguru handler
+
+ if not IS_FROZEN:
+ logger.add(**CONSOLE_CONFIG)
+ logger.add(**ALL_LOGS_CONFIG)
+ logger.add(**ERROR_LOGS_CONFIG)
+
+ logger.info("YTSage logger initialized")
+
+
+init_logger()
+
+__all__ = ["logger"]