From 41d35060c1261dd3e279b5006db43dddcd2ebe1b Mon Sep 17 00:00:00 2001 From: oop7 <110548351+oop7@users.noreply.github.com> Date: Sat, 25 Oct 2025 19:49:46 +0300 Subject: [PATCH] Remove yt-dlp Python package usage; use binary only Refactored all modules to remove dependency on the yt-dlp Python package, switching to subprocess calls to the yt-dlp binary for all extraction and update operations. Update logic now exclusively manages the app's own yt-dlp binary and no longer supports pip-based updates. Simplified format/audio detection and thumbnail extraction to use CLI heuristics and subprocess output. This improves reliability and avoids Python package conflicts. --- src/core/ytsage_downloader.py | 124 +-------- src/core/ytsage_yt_dlp.py | 33 +-- .../ytsage_dialogs_update.py | 245 ++---------------- src/gui/ytsage_gui_main.py | 224 +--------------- src/gui/ytsage_gui_video_info.py | 81 +++--- 5 files changed, 93 insertions(+), 614 deletions(-) diff --git a/src/core/ytsage_downloader.py b/src/core/ytsage_downloader.py index 7aace01..ece6bef 100644 --- a/src/core/ytsage_downloader.py +++ b/src/core/ytsage_downloader.py @@ -15,14 +15,6 @@ from src.utils.ytsage_logger import logger # Shorthand for localization _ = LocalizationManager.get_text -try: - import yt_dlp # Keep yt_dlp import here - only downloader uses it. - - YT_DLP_AVAILABLE = True -except ImportError: - YT_DLP_AVAILABLE = False - logger.warning("yt-dlp not available at startup, will be downloaded at runtime") - class SignalManager(QObject): update_formats = Signal(list) @@ -162,62 +154,10 @@ class DownloadThread(QThread): def check_file_exists(self) -> bool | None: """Check if the file already exists before downloading""" - try: - 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": 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, - ) - - # Add proxy settings if specified - if self.proxy_url: - ydl_opts_check["proxy"] = self.proxy_url - - if self.geo_proxy_url: - ydl_opts_check["geo_verification_proxy"] = self.geo_proxy_url - - if YT_DLP_AVAILABLE: - with yt_dlp.YoutubeDL(ydl_opts_check) as ydl: - info = ydl.extract_info(self.url, download=False) - - # Handle cases where info extraction fails silently - if not info: - logger.debug("Failed to extract info during file existence check. Skipping check.") - return False # Proceed with download attempt - - # Get the title and sanitize it for filename - title = info.get("title", "video") - # Don't remove colons and other special characters yet - logger.debug(f"Original video title: {title}") - - # Get resolution for better matching - resolution = "" - for format_info in info.get("formats", []): - if format_info.get("format_id") == self.format_id: - resolution = format_info.get("resolution", "") - break - - logger.debug(f"Resolution: {resolution}") - else: - logger.debug("yt-dlp not available, skipping file existence check") - return False # Proceed with download attempt - - except Exception as e: - logger.exception(f"Error checking file existence: {e}") - return None + # This method is kept for backwards compatibility but always returns False + # to proceed with download. File existence is now handled by yt-dlp CLI itself. + logger.debug("Skipping file existence check (handled by yt-dlp)") + return False # Proceed with download attempt def _build_yt_dlp_command(self) -> list: """Build the yt-dlp command line with all options for direct execution.""" @@ -231,27 +171,10 @@ class DownloadThread(QThread): # Strip the -drc suffix if present to fix issues with certain audio formats clean_format_id = self.format_id.split("-drc")[0] if "-drc" in self.format_id else self.format_id - # Check if this is an audio-only format - is_audio_format = False - try: - if YT_DLP_AVAILABLE: - ydl_opts = { - "logger": logger, - "quiet": True, - "no_warnings": True, - "skip_download": True, - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=False) or {} - for fmt in info.get("formats", []): - if fmt.get("format_id") == clean_format_id: - if fmt.get("vcodec") == "none" or "audio only" in fmt.get("format_note", "").lower(): - is_audio_format = True - logger.debug(f"Detected audio-only format for ID: {clean_format_id}") - break - except Exception as e: - logger.exception(f"Error checking if format is audio-only: {e}") - + # Use a simple heuristic: if format_id contains 'audio' or ends with 'a', treat as audio-only + # This avoids the need for Python API metadata extraction + is_audio_format = "audio" in clean_format_id.lower() or clean_format_id.endswith("a") + # For audio-only formats, don't try to merge with video if is_audio_format: cmd.extend(["-f", clean_format_id]) @@ -261,35 +184,10 @@ class DownloadThread(QThread): logger.debug(f"Using video format selection with audio: {clean_format_id}+bestaudio/best") # Determine output format based on the selected format ID - only for video formats + # Note: This is a best-effort approach without Python API metadata if not is_audio_format: - 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, "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 - for fmt in info.get("formats", []): - if fmt.get("format_id") == clean_format_id: - format_ext = fmt.get("ext") - break - # If not found, try the original ID as fallback - if not format_ext: - for fmt in info.get("formats", []): - if fmt.get("format_id") == self.format_id: - format_ext = fmt.get("ext") - break - - if format_ext: - logger.debug(f"Detected format extension: {format_ext}") - # Ensure output matches the selected format - only for video formats - cmd.extend(["--merge-output-format", format_ext]) - except Exception as e: - logger.exception(f"Error detecting format extension: {e}") - # If we can't determine the format, don't specify merge-output-format - pass + # Let yt-dlp handle format detection automatically via CLI + logger.debug("Letting yt-dlp CLI determine output format automatically") else: # If no specific format ID, use resolution-based sorting (-S) res_value = self.resolution if self.resolution else "720" # Default to 720p if no resolution specified diff --git a/src/core/ytsage_yt_dlp.py b/src/core/ytsage_yt_dlp.py index ed76240..e440c94 100644 --- a/src/core/ytsage_yt_dlp.py +++ b/src/core/ytsage_yt_dlp.py @@ -480,9 +480,10 @@ class YtdlpSetupDialog(QDialog): def check_ytdlp_binary() -> Optional[Path]: """ - Check if yt-dlp binary exists in the expected location. + Check if yt-dlp binary exists in the app's bin directory ONLY. + We now ignore system PATH and only use our managed binary. Returns: - Path or None: Path to yt-dlp binary if found, None otherwise + Path or None: Path to yt-dlp binary if found in app bin, None otherwise """ exe_path = YTDLP_APP_BIN_PATH if exe_path.exists(): @@ -493,33 +494,11 @@ def check_ytdlp_binary() -> Optional[Path]: logger.info(f"Fixed permissions on yt-dlp at {exe_path}") except Exception as e: logger.exception(f"Could not set executable permissions on {exe_path}: {e}") + logger.info(f"Found yt-dlp in app bin directory: {exe_path}") return exe_path - # If not found in app directory, check if yt-dlp is available in PATH - try: - # Use subprocess to check if yt-dlp is available - if OS_NAME == "Windows": - # On Windows, use 'where' command and hide console window - # Extra logic moved to src\utils\ytsage_constants.py - - result = subprocess.run( - ["where", "yt-dlp"], capture_output=True, text=True, check=False, creationflags=SUBPROCESS_CREATIONFLAGS - ) - if result.returncode == 0 and result.stdout.strip(): - yt_dlp_path = result.stdout.strip().split("\n")[0] - logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}") - return Path(yt_dlp_path) - else: - # On Unix systems, use 'which' command - result = subprocess.run(["which", "yt-dlp"], capture_output=True, text=True, check=False) - if result.returncode == 0 and result.stdout.strip(): - yt_dlp_path = result.stdout.strip() - logger.info(f"Found yt-dlp in PATH: {yt_dlp_path}") - return Path(yt_dlp_path) - except Exception as e: - logger.exception(f"Error checking for yt-dlp in PATH: {e}") - # We're only interested in our app-specific installation or system PATH - + # Binary not found in app directory - return None to trigger setup + logger.warning(f"yt-dlp binary not found in app bin directory: {exe_path}") return None diff --git a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py index 66fcce4..8937bca 100644 --- a/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py +++ b/src/gui/ytsage_gui_dialogs/ytsage_dialogs_update.py @@ -1,13 +1,12 @@ """ Update-related dialogs and threads for YTSage application. -Contains dialogs and background threads for checking and performing yt-dlp updates. +Contains dialogs and background threads for checking and performing yt-dlp binary updates. +Note: This module only handles binary updates. Python package updates have been removed. """ import os import subprocess -import sys import time -from importlib.metadata import PackageNotFoundError from pathlib import Path import requests @@ -25,30 +24,6 @@ _ = LocalizationManager.get_text from src.utils.ytsage_localization import _ 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 - -try: - import yt_dlp - - YT_DLP_AVAILABLE = True -except ImportError: - YT_DLP_AVAILABLE = False - class VersionCheckThread(QThread): finished = Signal(str, str, str) # current_version, latest_version, error_message @@ -73,31 +48,20 @@ class VersionCheckThread(QThread): ) if result.returncode == 0: current_version = result.stdout.strip() - else: # Try fallback if command failed - if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] - else: - error_message = "yt-dlp not available." - self.finished.emit(current_version, latest_version, error_message) + else: + error_message = "yt-dlp binary not accessible." + self.finished.emit(current_version, latest_version, error_message) return except subprocess.TimeoutExpired: - # Try fallback if timeout - if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] - else: - error_message = "yt-dlp version check timed out and package not found." - self.finished.emit(current_version, latest_version, error_message) - return - except Exception: - # Fallback to importing yt_dlp package directly if subprocess fails - if YT_DLP_AVAILABLE: - current_version = yt_dlp.version.__version__ # type: ignore[reportAttributeAccessIssue] - else: - error_message = "yt-dlp not found or accessible." - self.finished.emit(current_version, latest_version, error_message) - return + error_message = "yt-dlp version check timed out." + self.finished.emit(current_version, latest_version, error_message) + return + except Exception as e: + error_message = f"yt-dlp not found or accessible: {e}" + self.finished.emit(current_version, latest_version, error_message) + return - # Get latest version from PyPI + # Get latest version from PyPI (yt-dlp releases are also published to PyPI) response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) response.raise_for_status() latest_version = response.json()["info"]["version"] @@ -135,33 +99,11 @@ class UpdateThread(QThread): self.update_finished.emit(False, _('update.error_getting_path', error=e)) return - # Extra logic moved to src\utils\ytsage_constants.py - self.update_progress.emit(20) - # Extra logic moved to src\utils\ytsage_constants.py - # Check if this is an app-managed binary by comparing paths safely - is_app_managed = False - try: - # Only compare if both files exist - if yt_dlp_path.exists() and YTDLP_APP_BIN_PATH.exists(): - is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH) - elif str(yt_dlp_path) == str(YTDLP_APP_BIN_PATH): - # If paths are identical as strings, consider it app-managed - is_app_managed = True - else: - # If app binary doesn't exist, this is definitely not app-managed - is_app_managed = False - except (OSError, IOError) as e: - logger.debug(f"Error comparing paths: {e}") - is_app_managed = False - - if is_app_managed: - self.update_status.emit(_('update.updating_binary')) - success = self._update_binary(yt_dlp_path) - else: - self.update_status.emit(_('update.updating_pip')) - success = self._update_via_pip() + # Update the binary (no more pip-based updates) + self.update_status.emit(_('update.updating_binary')) + success = self._update_binary(yt_dlp_path) if success: self.update_progress.emit(100) @@ -219,77 +161,6 @@ class UpdateThread(QThread): self.update_status.emit(_('update.unexpected_error', error=e)) return False - def _update_via_pip(self) -> bool: - """Update yt-dlp via pip.""" - try: - self.update_status.emit(_('update.checking_pip')) - self.update_progress.emit(30) - - # Get current version - try: - current_version = get_version("yt-dlp") - self.update_status.emit(_('update.current_version', version=current_version)) - except PackageNotFoundError: - self.update_status.emit(_('update.not_found_pip')) - current_version = "0.0.0" - - self.update_progress.emit(40) - - # Get the latest version from PyPI - self.update_status.emit(_('update.checking_latest')) - response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) - - if response.status_code != 200: - self.update_status.emit(_('update.failed_check_updates')) - return False - - data = response.json() - latest_version = data["info"]["version"] - self.update_status.emit(_('update.latest_version', version=latest_version)) - self.update_progress.emit(50) - - # Compare versions - if version.parse(latest_version) > version.parse(current_version): - self.update_status.emit(_('update.updating_from_to', current=current_version, latest=latest_version)) - self.update_progress.emit(60) - - try: - # Run pip update with timeout - self.update_status.emit(_('update.running_pip_install')) - update_result = subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"], - capture_output=True, - text=True, - check=False, - timeout=300, # 5 minute timeout for pip install - creationflags=SUBPROCESS_CREATIONFLAGS, - ) - - self.update_progress.emit(85) - - if update_result.returncode == 0: - self.update_status.emit(_('update.pip_completed')) - self.update_progress.emit(95) - return True - else: - self.update_status.emit(_('update.pip_failed', error=update_result.stderr)) - return False - - except subprocess.TimeoutExpired: - self.update_status.emit(_("update.pip_timeout")) - return False - except Exception as e: - self.update_status.emit(_('update.error_pip_update', error=e)) - return False - else: - self.update_status.emit(_("update.already_up_to_date")) - self.update_progress.emit(95) - return True - - except Exception as e: - self.update_status.emit(_('update.pip_update_failed', error=e)) - return False - class YTDLPUpdateDialog(QDialog): def __init__(self, parent=None) -> None: @@ -569,35 +440,14 @@ class AutoUpdateThread(QThread): self.update_finished.emit(False, f"Critical error: {e}") def _perform_update(self) -> bool: - """Perform the actual update using similar logic to UpdateThread but without UI feedback.""" + """Perform the actual binary update.""" try: # Get the yt-dlp path yt_dlp_path = get_yt_dlp_path() - # Extra logic moved to src\utils\ytsage_constants.py - - # Check if this is an app-managed binary by comparing paths safely - is_app_managed = False - try: - # Only compare if both files exist - if yt_dlp_path.exists() and YTDLP_APP_BIN_PATH.exists(): - is_app_managed = yt_dlp_path.samefile(YTDLP_APP_BIN_PATH) - elif str(yt_dlp_path) == str(YTDLP_APP_BIN_PATH): - # If paths are identical as strings, consider it app-managed - is_app_managed = True - else: - # If app binary doesn't exist, this is definitely not app-managed - is_app_managed = False - except (OSError, IOError) as e: - logger.debug(f"AutoUpdateThread: Error comparing paths: {e}") - is_app_managed = False - - if is_app_managed: - logger.info("AutoUpdateThread: Updating app-managed yt-dlp binary...") - return self._update_binary(yt_dlp_path) - else: - logger.info("AutoUpdateThread: Updating system yt-dlp via pip...") - return self._update_via_pip() + # Always update the binary (no more pip-based updates) + logger.info("AutoUpdateThread: Updating yt-dlp binary...") + return self._update_binary(yt_dlp_path) except Exception as e: logger.exception(f"AutoUpdateThread: Error in _perform_update: {e}") @@ -636,58 +486,3 @@ class AutoUpdateThread(QThread): except Exception as e: logger.exception(f"AutoUpdateThread: Unexpected error during update: {e}") return False - - def _update_via_pip(self) -> bool: - """Update yt-dlp via pip (silent version).""" - try: - logger.info("AutoUpdateThread: Checking current pip installation...") - - # Get current version - try: - current_version = get_version("yt-dlp") - logger.info(f"AutoUpdateThread: Current version: {current_version}") - except PackageNotFoundError: - logger.warning("AutoUpdateThread: yt-dlp not found via pip, attempting installation...") - current_version = "0.0.0" - - # Get the latest version from PyPI - logger.info("AutoUpdateThread: Checking for latest version...") - response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10) - - if response.status_code != 200: - logger.error("AutoUpdateThread: Failed to check for updates") - return False - - data = response.json() - latest_version = data["info"]["version"] - logger.info(f"AutoUpdateThread: Latest version: {latest_version}") - - # Compare versions - if version.parse(latest_version) > version.parse(current_version): - logger.info(f"AutoUpdateThread: Updating from {current_version} to {latest_version}...") - - # Extra logic moved to src\utils\ytsage_constants.py - - # Run pip update - logger.info("AutoUpdateThread: Running pip install --upgrade...") - update_result = subprocess.run( - [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"], - capture_output=True, - text=True, - check=False, - creationflags=SUBPROCESS_CREATIONFLAGS, - ) - - if update_result.returncode == 0: - logger.info("AutoUpdateThread: Pip update completed successfully!") - return True - else: - logger.error(f"AutoUpdateThread: Pip update failed: {update_result.stderr}") - return False - else: - logger.info("AutoUpdateThread: yt-dlp is already up to date!") - return True - - except Exception as e: - logger.exception(f"AutoUpdateThread: Pip update failed: {e}") - return False diff --git a/src/gui/ytsage_gui_main.py b/src/gui/ytsage_gui_main.py index 8c824c9..9645364 100644 --- a/src/gui/ytsage_gui_main.py +++ b/src/gui/ytsage_gui_main.py @@ -48,13 +48,15 @@ from src.utils.ytsage_logger import logger from src.utils.ytsage_config_manager import ConfigManager from src.utils.ytsage_localization import LocalizationManager, _ -try: - import yt_dlp - from yt_dlp.utils import DownloadError, ExtractorError +# Note: yt-dlp Python package removed - using binary-only approach +# DownloadError and ExtractorError definitions kept for compatibility +class DownloadError(Exception): + """Compatibility class for yt-dlp DownloadError""" + pass - YT_DLP_AVAILABLE = True -except ImportError: - YT_DLP_AVAILABLE = False +class ExtractorError(Exception): + """Compatibility class for yt-dlp ExtractorError""" + pass class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from mixins @@ -65,10 +67,6 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from saved_language = ConfigManager.get("language") or "en" LocalizationManager.initialize(saved_language) - # Log startup warnings for missing dependencies - if not YT_DLP_AVAILABLE: - logger.warning("yt-dlp not available at startup, will be downloaded at runtime") - # Check for FFmpeg before proceeding if not check_ffmpeg(): self.show_ffmpeg_dialog() @@ -722,210 +720,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin): # Inherit from playlist_id = url.split("list=")[1].split("&")[0] url = f"https://www.youtube.com/playlist?list={playlist_id}" - # Check if yt-dlp Python module is available - if not YT_DLP_AVAILABLE: - # Use subprocess to call yt-dlp executable - self._analyze_url_with_subprocess(url) - return - - # 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, - "force_generic_extractor": False, - "ignoreerrors": False, # Set to False to catch errors properly - "no_color": True, - "verbose": True, - "cookiefile": None, - } - - # Add cookies argument if cookie file path is set - 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, - ) - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - try: - basic_info = ydl.extract_info(url, download=False) - if not basic_info: - logger.error("Could not extract basic video information") - self.signals.update_status.emit( - _("main_ui.error_extract_info") - ) - # 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: - 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)) - self.signals.update_status.emit(user_friendly_error) - return - - self.signals.update_status.emit(_("main_ui.analyzing_extracting_detailed")) - # 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, - "allsubtitles": True, - "writeautomaticsub": True, - "playliststart": 1, - "playlistend": 1, - "youtube_include_dash_manifest": True, - "youtube_include_hls_manifest": True, - "no_warnings": True, # <-- Add flag here for detailed extraction - } - - # Add cookies argument if cookie file path is set - 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, - ) - - # Use a separate options dict for the detailed extraction - with yt_dlp.YoutubeDL(ydl_opts_detail) as ydl_detail: - try: - self.signals.update_status.emit(_("main_ui.analyzing_processing_video")) - if basic_info.get("_type") == "playlist": - self.is_playlist = True - self.playlist_info = basic_info - self.selected_playlist_items = None # Reset selection for new playlist - self.playlist_entries = [entry for entry in basic_info.get("entries", []) if entry] # Store entries - - # Ensure there are entries before proceeding - if not self.playlist_entries: - logger.error("Playlist contains no valid videos.") - self.signals.update_status.emit(_("errors.playlist_no_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: - logger.error("Could not get URL for the first playlist video.") - self.signals.update_status.emit(_("errors.playlist_no_url")) - 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 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 = _("playlist.display_format", - title=basic_info.get('title', _('playlist.unknown')), - count=len(self.playlist_entries)) - - # update signal method from QMetaObject.invokeMethod to signals - self.signals.playlist_info_label_text.emit(playlist_text) - self.signals.playlist_info_label_visible.emit(True) - - # Show playlist selection BUTTON - # update signal method from QMetaObject.invokeMethod to signals - self.signals.playlist_select_btn_text.emit(_("main_ui.select_videos_all")) - self.signals.playlist_select_btn_visible.emit(True) - - else: # Single video - self.is_playlist = False - # Use ydl_detail instance here too for consistency - self.video_info = ydl_detail.extract_info(url, download=False) - self.playlist_entries = [] # Clear entries - self.selected_playlist_items = None # Clear selection - - # Hide playlist info label and button - # update signal method from QMetaObject.invokeMethod to signals - self.signals.playlist_info_label_visible.emit(False) - self.signals.playlist_select_btn_visible.emit(False) - - # Verify we have format information - if not self.video_info or "formats" not in self.video_info: - logger.debug(f"video_info keys: {self.video_info.keys() if self.video_info else 'None'}") - self.signals.update_status.emit(_("main_ui.error_no_format_info")) - self.signals.playlist_info_label_visible.emit(False) - self.signals.playlist_select_btn_visible.emit(False) - return - - self.signals.update_status.emit(_("main_ui.analyzing_processing_formats")) - self.all_formats = self.video_info["formats"] - - # Update UI - self.update_video_info(self.video_info) - - # Update thumbnail - self.signals.update_status.emit(_("main_ui.analyzing_loading_thumbnail")) - # 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 - if self.save_thumbnail: - self.download_thumbnail_file( - self.video_url, self.path_input.text() # type: ignore[reportAttributeAccessIssue] - ) - - # --- Subtitle Handling --- - self.signals.update_status.emit(_("main_ui.analyzing_processing_subtitles")) - # Clear previous selections when analyzing a new video - self.selected_subtitles = [] - self.available_subtitles = self.video_info.get("subtitles", {}) - self.available_automatic_subtitles = self.video_info.get("automatic_captions", {}) - # Update the UI elements related to subtitle selection state - # update signal method from QMetaObject.invokeMethod to signals - self.signals.selected_subs_label_text.emit(_("main_ui.subtitles_selected", count=0)) - - # QMetaObject.invokeMethod( - # self.subtitle_select_btn, - # b"setProperty", - # Qt.ConnectionType.QueuedConnection, - # Q_ARG(str, b"subtitlesSelected"), - # Q_ARG(bool, False), - # ) # <-- COMMENT OUT THIS LINE - - # REMOVE the merge_subs_checkbox update call from here - # QMetaObject.invokeMethod( - # self.merge_subs_checkbox, - # b"setEnabled", - # Qt.ConnectionType.QueuedConnection, - # Q_ARG(bool, False), - # ) - - # Update format table - self.signals.update_status.emit(_("main_ui.analyzing_updating_table")) - self.video_button.setChecked(True) - self.audio_button.setChecked(False) - self.filter_formats() - self.signals.update_status.emit(_("main_ui.analysis_complete")) - - except Exception as e: - 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)) - self.signals.update_status.emit(user_friendly_error) - return + # Always use subprocess to call yt-dlp binary (Python package removed) + self._analyze_url_with_subprocess(url) except Exception as e: logger.exception(f"Error in analysis: {e}") diff --git a/src/gui/ytsage_gui_video_info.py b/src/gui/ytsage_gui_video_info.py index 465194c..8098588 100644 --- a/src/gui/ytsage_gui_video_info.py +++ b/src/gui/ytsage_gui_video_info.py @@ -385,52 +385,63 @@ class VideoInfoMixin: return False try: - # Import yt_dlp locally to avoid import errors when yt-dlp is not installed - from yt_dlp import YoutubeDL + # Note: yt_dlp Python package removed - this feature now uses subprocess + # Extract thumbnail info using yt-dlp CLI instead + from src.core.ytsage_yt_dlp import get_yt_dlp_path + from src.utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS + import subprocess + import json logger.debug(f"Attempting to save thumbnail for URL: {video_url}") - ydl_opts = { - "logger": logger, - "quiet": True, - "skip_download": True, - "force_generic_extractor": False, - "no_warnings": True, - "extract_flat": False, - } + ytdlp_path = get_yt_dlp_path() + + # Use yt-dlp CLI to extract thumbnail info + result = subprocess.run( + [ytdlp_path, "--dump-json", "--skip-download", video_url], + capture_output=True, + text=True, + timeout=30, + creationflags=SUBPROCESS_CREATIONFLAGS, + ) + + if result.returncode != 0: + logger.error(f"Failed to extract video info: {result.stderr}") + return False + + info = json.loads(result.stdout) + thumbnails = info.get("thumbnails", []) - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(video_url, download=False) - thumbnails = info.get("thumbnails", []) + if not thumbnails: + logger.info("No thumbnails available") + return False - if not thumbnails: - logger.info("No thumbnails available") + thumbnail_url = max( + thumbnails, + key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0), + ).get("url") - thumbnail_url = max( - thumbnails, - key=lambda t: (t.get("height", 0) or 0) * (t.get("width", 0) or 0), - ).get("url") + if not thumbnail_url: + logger.info("Failed to extract thumbnail URL") + return False - if not thumbnail_url: - logger.info("Failed to extract thumbnail URL") + # Download using requests + response = requests.get(thumbnail_url) + response.raise_for_status() - # Download using requests - response = requests.get(thumbnail_url) - response.raise_for_status() + # Save the thumbnail + thumb_dir = Path(path).joinpath("Thumbnails") + thumb_dir.mkdir(exist_ok=True) - # Save the thumbnail - thumb_dir = Path(path).joinpath("Thumbnails") - thumb_dir.mkdir(exist_ok=True) + filename = f"{self.sanitize_filename(info['title'])}.jpg" + thumbnail_path = thumb_dir.joinpath(filename) - filename = f"{self.sanitize_filename(info['title'])}.jpg" - thumbnail_path = thumb_dir.joinpath(filename) + with open(thumbnail_path, "wb") as f: + f.write(response.content) - with open(thumbnail_path, "wb") as f: - f.write(response.content) - - logger.info(f"Thumbnail saved to: {thumbnail_path}") - self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}") - return True + logger.info(f"Thumbnail saved to: {thumbnail_path}") + self.signals.update_status.emit(f"✅ Thumbnail saved: {filename}") + return True except Exception as e: error_msg = f"❌ Thumbnail error: {e}"