v4.9.0 - Refactor (#41)

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

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

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

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

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

- imporve: return type hint for function.

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

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

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

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

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

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

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

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

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

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

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

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

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

download_path = ConfigManager.get("download_path")

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

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

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

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

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

* remove: import check, it should always work.

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

* chang back to checking import for ytdlp

* Bump version to 4.8.1

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

* Update asset paths in build workflows

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

* Bump version to 4.8.2

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

* Fix asset include path in Windows build workflow

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

* Update release tag examples in CI/CD README

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

* Fix include-files mapping in Windows build workflow

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

* Remove redundant comments in build-windows workflow

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

* Refactor Windows build to use cx_Freeze setup script

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

* Bump version to 4.8.3

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

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

---------

Co-authored-by: Your Name <mohamed.mohamed112@ai.mnu.edu.eg>
This commit is contained in:
Viren Hirpara
2025-09-10 01:31:19 +05:30
committed by GitHub
parent c140acef5a
commit e9de913b47
23 changed files with 825 additions and 714 deletions
+31 -32
View File
@@ -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
+21 -15
View File
@@ -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
-238
View File
@@ -1,238 +0,0 @@
"""
YTSage logging configuration using loguru.
This module provides centralized logging configuration for the entire YTSage application.
It replaces the inefficient print statements with structured logging using loguru.
"""
import sys
from pathlib import Path
from src.utils.ytsage_constants import APP_LOG_DIR
# Try to import loguru, but handle case where it might not be available
try:
from loguru import logger
LOGURU_AVAILABLE = True
except ImportError:
LOGURU_AVAILABLE = False
# Create a dummy logger class that does nothing
class DummyLogger:
def info(self, *args, **kwargs):
pass
def debug(self, *args, **kwargs):
pass
def warning(self, *args, **kwargs):
pass
def error(self, *args, **kwargs):
pass
def critical(self, *args, **kwargs):
pass
def remove(self, *args, **kwargs):
pass
def add(self, *args, **kwargs):
pass
def bind(self, *args, **kwargs):
return self
@property
def _core(self):
class Core:
handlers = []
return Core()
logger = DummyLogger()
def setup_logging():
"""
Configure loguru logging for YTSage application.
Sets up multiple log levels and outputs:
- Console output for INFO and above
- File output for DEBUG and above
- Separate error log file for ERROR and above
"""
if not LOGURU_AVAILABLE:
return logger
# Remove default logger to avoid duplicate output
try:
logger.remove()
except Exception:
pass
# Get the application data directory with fallbacks
try:
# logic moved to src\utils\ytsage_constants.py
log_dir = APP_LOG_DIR
except Exception:
# Ultimate fallback - use current directory
log_dir = Path.cwd() / "logs"
# Create log directory if it doesn't exist
try:
log_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# If we can't create the log directory, fall back to current directory
log_dir = Path.cwd()
try:
log_dir.mkdir(exist_ok=True)
except Exception:
pass # If we still can't create it, we'll just log to console
# Console handler - INFO and above, with colors
# Check if stdout is available (it might be None in PyInstaller windowed apps)
stdout_available = sys.stdout is not None
if stdout_available:
try:
logger.add(
sys.stdout,
level="INFO",
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True,
catch=True,
)
except Exception:
# Fallback to basic console logging without colors
try:
logger.add(
sys.stdout,
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
catch=True,
)
except Exception:
stdout_available = False
# If stdout is not available, try stderr or skip console logging entirely
if not stdout_available:
try:
if sys.stderr is not None:
logger.add(
sys.stderr,
level="WARNING", # Only warnings and errors to stderr
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}",
catch=True,
)
except Exception:
# If even stderr fails, we'll rely only on file logging
pass
# Only add file handlers if we successfully created a log directory
if log_dir and log_dir.exists():
try:
# Main log file - DEBUG and above, with rotation
logger.add(
log_dir / "ytsage.log",
level="DEBUG",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
rotation="10 MB", # Rotate when file reaches 10MB
retention="7 days", # Keep logs for 7 days
compression="zip", # Compress old logs
catch=True,
)
# Error log file - ERROR and above only
logger.add(
log_dir / "ytsage_errors.log",
level="ERROR",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
rotation="5 MB",
retention="30 days", # Keep error logs longer
compression="zip",
catch=True,
)
except Exception as e:
# If file logging fails, just log to console
logger.warning(f"Could not set up file logging: {e}")
# Log startup message if we have any handlers
if logger._core.handlers:
logger.info("YTSage logging system initialized")
if log_dir and log_dir.exists():
logger.debug(f"Log directory: {log_dir}")
else:
logger.warning("File logging disabled - could not create log directory")
# If no handlers were successfully added, add a null handler to prevent errors
if not logger._core.handlers:
# Add a minimal handler that just discards messages
# This prevents loguru from complaining about no handlers
import tempfile
try:
# Try to add a temporary file handler as last resort
temp_log = Path(tempfile.gettempdir()) / "ytsage_temp.log"
logger.add(temp_log, level="ERROR", catch=True)
except Exception:
# If even that fails, we're in a very restricted environment
# loguru should handle this gracefully with its internal fallbacks
pass
return logger
def get_logger(name: str | None = None):
"""
Get a logger instance for a specific module.
Args:
name: Name of the module/component requesting the logger
Returns:
Configured logger instance
"""
if name:
return logger.bind(name=name)
return logger
# Initialize logging when module is imported - with maximum safety
_setup_complete = False
def safe_setup():
"""Safely initialize logging with multiple fallback strategies."""
global _setup_complete
if _setup_complete:
return logger
try:
setup_logging()
_setup_complete = True
except Exception:
# If all else fails, create an even simpler logger that just prints
if LOGURU_AVAILABLE:
try:
logger.remove()
except Exception:
pass
# At this point, just ensure we have something that won't crash
_setup_complete = True
return logger
# Try to set up logging, but don't let it crash the module import
try:
safe_setup()
except Exception:
# Ultimate fallback - the module will still import successfully
pass
# Export the main logger for convenience
__all__ = ["logger", "get_logger", "setup_logging"]
+122 -94
View File
@@ -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}"
+8 -8
View File
@@ -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