49dde84481
Emit progress updates during URL analysis and simulate gradual progress for long-running yt-dlp operations. Added QTimer import and a progress_update Signal on AnalysisThread, with emits at multiple analysis milestones (e.g. 15, 30, 60, 70, 85, 90, 92, 95, 100). In AnalysisMixin introduced _analysis_timer and _fake_progress, connected thread progress to _handle_analysis_progress, and implemented a _update_fake_progress timer that slowly advances the progress bar between real updates. Timers are stopped on real progress, completion or error and the UI progress is reset appropriately to improve UX during long extraction steps.
437 lines
17 KiB
Python
437 lines
17 KiB
Python
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
|
|
|
|
import json
|
|
import subprocess
|
|
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
|
|
from PySide6.QtWidgets import QMessageBox
|
|
|
|
from ..core.ytsage_utils import validate_video_url, parse_yt_dlp_error
|
|
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
|
from ..utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS
|
|
from ..utils.ytsage_localization import _
|
|
from ..utils.ytsage_logger import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from .ytsage_gui_main import YTSageApp
|
|
|
|
|
|
class AnalysisThread(QThread):
|
|
"""
|
|
Thread-safe QThread for URL analysis.
|
|
All results are passed back via signals to ensure thread safety.
|
|
"""
|
|
# Signals for status updates
|
|
status_update = Signal(str)
|
|
progress_update = Signal(int) # Signal for progress bar updates
|
|
|
|
# Signals for playlist UI
|
|
playlist_info_visible = Signal(bool)
|
|
playlist_info_text = Signal(str)
|
|
playlist_select_btn_visible = Signal(bool)
|
|
playlist_select_btn_text = Signal(str)
|
|
|
|
# Signal for analysis results - passes all data at once
|
|
analysis_complete = Signal(dict)
|
|
|
|
# Signal for errors
|
|
analysis_error = Signal(str)
|
|
|
|
# Signal when thread finishes (success or failure)
|
|
analysis_finished = Signal()
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
cookie_file_path: Optional[str] = None,
|
|
browser_cookies_option: Optional[str] = None,
|
|
proxy_url: Optional[str] = None,
|
|
geo_proxy_url: Optional[str] = None,
|
|
parent=None
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.url = url
|
|
self.cookie_file_path = cookie_file_path
|
|
self.browser_cookies_option = browser_cookies_option
|
|
self.proxy_url = proxy_url
|
|
self.geo_proxy_url = geo_proxy_url
|
|
self._cancelled = False
|
|
|
|
def cancel(self) -> None:
|
|
"""Request cancellation of the analysis."""
|
|
self._cancelled = True
|
|
|
|
def run(self) -> None:
|
|
"""Main thread execution - performs URL analysis."""
|
|
try:
|
|
self.status_update.emit(_("main_ui.analyzing_extracting_basic"))
|
|
self.progress_update.emit(15)
|
|
|
|
url = self.url
|
|
# Clean up the URL to handle both playlist and video URLs
|
|
if "list=" in url and "watch?v=" in url:
|
|
playlist_id = url.split("list=")[1].split("&")[0]
|
|
url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
|
|
|
self._analyze_url_with_subprocess(url)
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error in analysis: {e}")
|
|
self.analysis_error.emit(_("errors.generic_error", error=str(e)))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
finally:
|
|
self.analysis_finished.emit()
|
|
|
|
def _add_auth_options(self, cmd: List[str]) -> None:
|
|
"""Add authentication and proxy options to command."""
|
|
if self.cookie_file_path:
|
|
cmd.extend(["--cookies", str(self.cookie_file_path)])
|
|
elif self.browser_cookies_option:
|
|
cmd.extend(["--cookies-from-browser", self.browser_cookies_option])
|
|
|
|
if self.proxy_url:
|
|
cmd.extend(["--proxy", self.proxy_url])
|
|
|
|
if self.geo_proxy_url:
|
|
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
|
|
|
|
def _analyze_url_with_subprocess(self, url: str) -> None:
|
|
"""Analyze URL using yt-dlp executable."""
|
|
if self._cancelled:
|
|
return
|
|
|
|
yt_dlp_path = get_yt_dlp_path()
|
|
if not yt_dlp_path:
|
|
logger.error("yt-dlp executable not found. Please install yt-dlp first.")
|
|
self.analysis_error.emit(_("errors.ytdlp_not_found"))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
self.status_update.emit(_("main_ui.analyzing_extracting_ytdlp"))
|
|
self.progress_update.emit(30) # This will trigger fake progress in UI
|
|
|
|
# Build command for basic info extraction
|
|
cmd = [yt_dlp_path, "--dump-single-json", "--flat-playlist", "--no-warnings", url]
|
|
self._add_auth_options(cmd)
|
|
|
|
logger.debug(f"Executing yt-dlp command: {cmd}")
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=300,
|
|
creationflags=SUBPROCESS_CREATIONFLAGS
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
logger.error("Analysis timed out")
|
|
self.analysis_error.emit(_("errors.timeout"))
|
|
return
|
|
|
|
if self._cancelled:
|
|
return
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"yt-dlp failed: {result.stderr}")
|
|
self.analysis_error.emit(_("errors.ytdlp_failed", error=result.stderr))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
json_lines = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
|
|
if not json_lines:
|
|
logger.error("No data returned from yt-dlp")
|
|
self.analysis_error.emit(_("errors.no_data_returned"))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
try:
|
|
first_info = json.loads(json_lines[0])
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Failed to parse yt-dlp output: {e}")
|
|
self.analysis_error.emit(_("errors.parse_failed", error=str(e)))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
if self._cancelled:
|
|
return
|
|
|
|
self.status_update.emit(_("main_ui.analyzing_processing_data"))
|
|
self.progress_update.emit(60)
|
|
|
|
# Prepare result data
|
|
result_data: Dict[str, Any] = {
|
|
"is_playlist": False,
|
|
"playlist_info": None,
|
|
"playlist_entries": [],
|
|
"video_info": None,
|
|
"all_formats": [],
|
|
"available_subtitles": {},
|
|
"available_automatic_subtitles": {},
|
|
"thumbnail_url": None,
|
|
}
|
|
|
|
if first_info.get("_type") == "playlist":
|
|
result_data["is_playlist"] = True
|
|
result_data["playlist_info"] = first_info
|
|
playlist_entries = first_info.get("entries", [])
|
|
result_data["playlist_entries"] = playlist_entries
|
|
|
|
if not playlist_entries:
|
|
logger.error("Playlist contains no valid videos.")
|
|
self.analysis_error.emit(_("errors.playlist_no_videos"))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
# Fetch full info for the first video to get formats
|
|
self.status_update.emit(_("main_ui.analyzing_fetching_first_video"))
|
|
self.progress_update.emit(70)
|
|
first_video_entry = playlist_entries[0]
|
|
first_video_url = first_video_entry.get("url")
|
|
|
|
cmd_single = [yt_dlp_path, "--dump-single-json", "--no-warnings", first_video_url]
|
|
self._add_auth_options(cmd_single)
|
|
|
|
try:
|
|
result_single = subprocess.run(
|
|
cmd_single, capture_output=True, text=True, timeout=60,
|
|
creationflags=SUBPROCESS_CREATIONFLAGS
|
|
)
|
|
if result_single.returncode == 0:
|
|
result_data["video_info"] = json.loads(result_single.stdout)
|
|
else:
|
|
result_data["video_info"] = first_video_entry
|
|
except subprocess.TimeoutExpired:
|
|
result_data["video_info"] = first_video_entry
|
|
|
|
if self._cancelled:
|
|
return
|
|
|
|
# Update playlist UI via signals
|
|
playlist_text = _("playlist.display_format",
|
|
title=first_info.get('title', _('playlist.unknown')),
|
|
count=len(playlist_entries))
|
|
self.playlist_info_text.emit(playlist_text)
|
|
self.playlist_info_visible.emit(True)
|
|
self.playlist_select_btn_text.emit(_("main_ui.select_videos_all"))
|
|
self.playlist_select_btn_visible.emit(True)
|
|
|
|
else:
|
|
# Handle single video
|
|
result_data["is_playlist"] = False
|
|
result_data["video_info"] = first_info
|
|
result_data["playlist_entries"] = []
|
|
result_data["playlist_info"] = None
|
|
|
|
# Hide playlist UI
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
|
|
# Verify we have format information
|
|
video_info = result_data["video_info"]
|
|
if not video_info or "formats" not in video_info:
|
|
logger.error("No format information available")
|
|
self.analysis_error.emit(_("errors.no_format_info"))
|
|
self.playlist_info_visible.emit(False)
|
|
self.playlist_select_btn_visible.emit(False)
|
|
return
|
|
|
|
self.status_update.emit(_("main_ui.analyzing_processing_formats_ytdlp"))
|
|
self.progress_update.emit(85)
|
|
result_data["all_formats"] = video_info.get("formats", [])
|
|
|
|
# Get thumbnail URL
|
|
self.status_update.emit(_("main_ui.analyzing_loading_thumbnail_ytdlp"))
|
|
self.progress_update.emit(90)
|
|
playlist_info = result_data.get("playlist_info") or {}
|
|
thumbnail_url = playlist_info.get("thumbnail") or video_info.get("thumbnail")
|
|
result_data["thumbnail_url"] = thumbnail_url
|
|
|
|
# Handle subtitles
|
|
self.status_update.emit(_("main_ui.analyzing_processing_subtitles_ytdlp"))
|
|
self.progress_update.emit(92)
|
|
result_data["available_subtitles"] = video_info.get("subtitles", {})
|
|
result_data["available_automatic_subtitles"] = video_info.get("automatic_captions", {})
|
|
|
|
self.status_update.emit(_("main_ui.analyzing_updating_table"))
|
|
self.progress_update.emit(95)
|
|
|
|
# Emit all results at once
|
|
self.analysis_complete.emit(result_data)
|
|
|
|
|
|
class AnalysisMixin:
|
|
"""Mixin class providing URL analysis functionality for YTSageApp."""
|
|
|
|
# Track the current analysis thread
|
|
_analysis_thread: Optional[AnalysisThread] = None
|
|
_analysis_timer: Optional[QTimer] = None
|
|
_fake_progress: int = 0
|
|
|
|
def analyze_url(self) -> None:
|
|
"""Start URL analysis in a background thread."""
|
|
self = cast("YTSageApp", self)
|
|
|
|
if self.is_updating_ytdlp:
|
|
QMessageBox.warning(self, _("update.update_in_progress_title"), _("update.update_in_progress_message"))
|
|
return
|
|
|
|
url = self.url_input.text().strip()
|
|
if not url:
|
|
self.signals.update_status.emit(_("main_ui.invalid_url_or_enter"))
|
|
if hasattr(self, "animate_widget_shake"):
|
|
self.animate_widget_shake(self.url_input)
|
|
return
|
|
|
|
# Validate URL before processing
|
|
is_valid, error_message = validate_video_url(url)
|
|
if not is_valid:
|
|
QMessageBox.warning(self, _("main_ui.error_title"), error_message)
|
|
if hasattr(self, "animate_widget_shake"):
|
|
self.animate_widget_shake(self.url_input)
|
|
return
|
|
|
|
# Cancel any existing analysis thread
|
|
if self._analysis_thread is not None and self._analysis_thread.isRunning():
|
|
self._analysis_thread.cancel()
|
|
self._analysis_thread.wait(1000) # Wait up to 1 second
|
|
|
|
# Reset analysis state and disable controls
|
|
self.analysis_completed = False
|
|
self.toggle_analysis_dependent_controls(enabled=False)
|
|
|
|
self.signals.update_status.emit(_("main_ui.analyzing_preparing"))
|
|
self.signals.update_progress.emit(0) # Reset progress
|
|
self.is_analyzing = True
|
|
|
|
# Stop any existing timer
|
|
if self._analysis_timer:
|
|
self._analysis_timer.stop()
|
|
self._analysis_timer = None
|
|
|
|
# Create and configure the analysis thread
|
|
self._analysis_thread = AnalysisThread(
|
|
url=url,
|
|
# ... arguments will be filled by ... usage below ...
|
|
cookie_file_path=self.cookie_file_path,
|
|
browser_cookies_option=self.browser_cookies_option,
|
|
proxy_url=self.proxy_url,
|
|
geo_proxy_url=self.geo_proxy_url,
|
|
parent=self
|
|
)
|
|
|
|
# Connect signals to handlers
|
|
self._analysis_thread.status_update.connect(self.signals.update_status.emit)
|
|
self._analysis_thread.progress_update.connect(self._handle_analysis_progress)
|
|
self._analysis_thread.playlist_info_visible.connect(self.signals.playlist_info_label_visible.emit)
|
|
self._analysis_thread.playlist_info_text.connect(self.signals.playlist_info_label_text.emit)
|
|
self._analysis_thread.playlist_select_btn_visible.connect(self.signals.playlist_select_btn_visible.emit)
|
|
self._analysis_thread.playlist_select_btn_text.connect(self.signals.playlist_select_btn_text.emit)
|
|
self._analysis_thread.analysis_complete.connect(self._on_analysis_complete)
|
|
self._analysis_thread.analysis_error.connect(self._on_analysis_error)
|
|
self._analysis_thread.analysis_finished.connect(self._on_analysis_finished)
|
|
|
|
# Start the thread
|
|
self._analysis_thread.start()
|
|
|
|
def _handle_analysis_progress(self, value: int) -> None:
|
|
"""Handle progress updates from analysis thread."""
|
|
self = cast("YTSageApp", self)
|
|
|
|
# Stop fake timer if running on any real update
|
|
if self._analysis_timer:
|
|
self._analysis_timer.stop()
|
|
self._analysis_timer = None
|
|
|
|
self.signals.update_progress.emit(value)
|
|
|
|
# If we hit the extraction phase (30%), start fake progress
|
|
if value == 30:
|
|
self._fake_progress = 30
|
|
self._analysis_timer = QTimer(self)
|
|
self._analysis_timer.timeout.connect(self._update_fake_progress)
|
|
self._analysis_timer.start(200) # Every 200ms
|
|
|
|
def _update_fake_progress(self) -> None:
|
|
"""Increment progress bar slowly during long operations."""
|
|
self = cast("YTSageApp", self)
|
|
|
|
# Asymptotically approach 85%
|
|
if self._fake_progress < 85:
|
|
# Slow down as we get higher
|
|
increment = 1
|
|
if self._fake_progress > 60:
|
|
if self._fake_progress % 3 == 0: # Slower
|
|
increment = 1
|
|
else:
|
|
increment = 0
|
|
|
|
if increment > 0:
|
|
self._fake_progress += increment
|
|
self.signals.update_progress.emit(self._fake_progress)
|
|
|
|
def _on_analysis_complete(self, result_data: Dict[str, Any]) -> None:
|
|
"""Handle successful analysis completion - runs in main thread."""
|
|
self = cast("YTSageApp", self)
|
|
|
|
# Stop fake timer
|
|
if self._analysis_timer:
|
|
self._analysis_timer.stop()
|
|
self._analysis_timer = None
|
|
|
|
self.signals.update_progress.emit(100)
|
|
|
|
# Update instance variables with results (safe - we're in main thread)
|
|
self.is_playlist = result_data["is_playlist"]
|
|
self.playlist_info = result_data["playlist_info"]
|
|
self.playlist_entries = result_data["playlist_entries"]
|
|
self.video_info = result_data["video_info"]
|
|
self.all_formats = result_data["all_formats"]
|
|
self.available_subtitles = result_data["available_subtitles"]
|
|
self.available_automatic_subtitles = result_data["available_automatic_subtitles"]
|
|
self.selected_playlist_items = None
|
|
self.selected_subtitles = []
|
|
|
|
# Update UI components (safe - we're in main thread)
|
|
self.update_video_info(self.video_info)
|
|
|
|
# Download thumbnail
|
|
thumbnail_url = result_data.get("thumbnail_url")
|
|
if thumbnail_url:
|
|
self.download_thumbnail(thumbnail_url)
|
|
|
|
# Save thumbnail if enabled
|
|
if self.save_thumbnail:
|
|
self.download_thumbnail_file(self.video_url, self.last_path)
|
|
|
|
# Update subtitle UI
|
|
self.signals.selected_subs_label_text.emit(_("main_ui.zero_selected"))
|
|
|
|
# Update format table
|
|
self.video_button.setChecked(True)
|
|
self.audio_button.setChecked(False)
|
|
self.filter_formats()
|
|
|
|
self.signals.update_status.emit(_("main_ui.analysis_complete"))
|
|
|
|
# Mark analysis as complete and enable controls
|
|
self.analysis_completed = True
|
|
self.toggle_analysis_dependent_controls(enabled=True)
|
|
|
|
def _on_analysis_error(self, error_message: str) -> None:
|
|
"""Handle analysis error - runs in main thread."""
|
|
self = cast("YTSageApp", self)
|
|
if self._analysis_timer:
|
|
self._analysis_timer.stop()
|
|
self._analysis_timer = None
|
|
self.signals.update_progress.emit(0)
|
|
self.signals.update_status.emit(error_message)
|
|
|
|
def _on_analysis_finished(self) -> None:
|
|
"""Handle analysis thread completion - runs in main thread."""
|
|
self = cast("YTSageApp", self)
|
|
self.is_analyzing = False
|