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.
This commit is contained in:
oop7
2025-10-25 19:49:46 +03:00
parent aab5fd9549
commit 41d35060c1
5 changed files with 93 additions and 614 deletions
@@ -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
+10 -214
View File
@@ -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}")
+46 -35
View File
@@ -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}"