diff --git a/plugin/main.py b/plugin/main.py index 013de58..6add860 100644 --- a/plugin/main.py +++ b/plugin/main.py @@ -22,6 +22,8 @@ verify_ffmpeg_zip, extract_ffmpeg, get_binaries_paths, + check_ytdlp_update_needed, + update_ytdlp_library, ) from results import ( init_results, @@ -32,10 +34,13 @@ download_ffmpeg_result, ffmpeg_setup_result, ffmpeg_not_found_result, + update_ytdlp_result, + ytdlp_update_in_progress_result, ) from ytdlp import CustomYoutubeDL PLUGIN_ROOT = os.path.dirname(os.path.abspath(__file__)) +LIB_PATH = os.path.abspath(os.path.join(PLUGIN_ROOT, "..", "lib")) EXE_PATH = os.path.join(PLUGIN_ROOT, "yt-dlp.exe") CHECK_INTERVAL_DAYS = 5 DEFAULT_DOWNLOAD_PATH = str(Path.home() / "Downloads") @@ -92,6 +97,33 @@ def query(query: str) -> ResultResponse: return send_results([invalid_result()]) query = query.replace("https://", "http://") + + # Check if yt-dlp library needs update before processing + update_lock = os.path.join(LIB_PATH, ".ytdlp_updating") + + # Check if update is in progress, but ignore stale locks + if os.path.exists(update_lock): + try: + lock_age = datetime.now() - datetime.fromtimestamp(os.path.getmtime(update_lock)) + if lock_age < timedelta(minutes=5): + return send_results([ytdlp_update_in_progress_result()]) + else: + try: + os.remove(update_lock) + except Exception: + # Best-effort cleanup of stale update lock; ignore failures as they are non-fatal. + pass + except Exception: + # If we can't check lock age, assume update is in progress to be safe + return send_results([ytdlp_update_in_progress_result()]) + + if check_ytdlp_update_needed(CHECK_INTERVAL_DAYS): + try: + import yt_dlp + current_version = yt_dlp.version.__version__ + except: + current_version = None + return send_results([update_ytdlp_result(current_version)]) ydl_opts = { "quiet": True, @@ -126,7 +158,7 @@ def query(query: str) -> ResultResponse: formats = sort_by_tbr(formats) elif sort == "FPS": formats = sort_by_fps(formats) - + results = [] if not verify_ffmpeg_binaries(): @@ -181,20 +213,42 @@ def download_ffmpeg_binaries(PLUGIN_ROOT) -> None: if not os.path.exists(FFMPEG_ZIP): return - zip_ok, zip_reason = verify_ffmpeg_zip(return_reason=True) + zip_ok, _ = verify_ffmpeg_zip(return_reason=True) if not zip_ok: - if zip_reason: - print(f"FFmpeg download validation failed: {zip_reason}") try: os.remove(FFMPEG_ZIP) except Exception: pass return - extracted, extract_reason = extract_ffmpeg() - if not extracted and extract_reason: - print(f"FFmpeg extraction failed: {extract_reason}") + extract_ffmpeg() + finally: + try: + if os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + + +@plugin.on_method +def update_ytdlp_library_action() -> None: + """Update the yt-dlp library when user clicks the update prompt.""" + lock_path = os.path.join(LIB_PATH, ".ytdlp_updating") + + # Create lock file to prevent concurrent updates + try: + os.makedirs(LIB_PATH, exist_ok=True) + with open(lock_path, "w") as lock_file: + lock_file.write("in-progress") + except Exception as _: + return + + try: + update_ytdlp_library() + except Exception as _: + return finally: + # Always remove lock file, even if update fails or is interrupted try: if os.path.exists(lock_path): os.remove(lock_path) diff --git a/plugin/results.py b/plugin/results.py index a6a8977..6428ae2 100644 --- a/plugin/results.py +++ b/plugin/results.py @@ -55,12 +55,47 @@ def ffmpeg_setup_result(issue) -> Result: ) +def update_ytdlp_result(current_version=None) -> Result: + subtitle = "Click to update yt-dlp library to the latest version." + if current_version: + subtitle = f"Current version: {current_version}. Click to update." + return Result( + Title="yt-dlp library update available!", + SubTitle=subtitle, + IcoPath="Images/app.png", + JsonRPCAction={"method": "update_ytdlp_library_action", "parameters": []}, + ) + + +def ytdlp_update_in_progress_result() -> Result: + return Result( + Title="yt-dlp update in progress...", + SubTitle="Please wait a moment and try again.", + IcoPath="Images/app.png", + ) + + def query_result( query, thumbnail, title, format, download_path, pref_video_path, pref_audio_path ) -> Result: + # Build subtitle with consistent spacing + subtitle_parts = [f"Res: {format['resolution']}"] + + if format.get('tbr') is not None: + subtitle_parts.append(f"({round(format['tbr'], 2)} kbps)") + + if format.get('filesize'): + size_mb = round(format['filesize'] / 1024 / 1024, 2) + subtitle_parts.append(f"Size: {size_mb}MB") + + if format.get('fps'): + subtitle_parts.append(f"FPS: {int(format['fps'])}") + + subtitle = " ┃ ".join(subtitle_parts) + return Result( Title=title, - SubTitle=f"Res: {format['resolution']} ({round(format['tbr'], 2)} kbps) {'┃ Size: ' + str(round(format['filesize'] / 1024 / 1024, 2)) + 'MB' if format.get('filesize') else ''} {'┃ FPS: ' + str(int(format['fps'])) if format.get('fps') else ''}", + SubTitle=subtitle, IcoPath=thumbnail or "Images/app.png", JsonRPCAction={ "method": "download", diff --git a/plugin/utils.py b/plugin/utils.py index fd0461d..0ed9cfa 100644 --- a/plugin/utils.py +++ b/plugin/utils.py @@ -1,8 +1,12 @@ import re import os import zipfile +import subprocess +import sys +from datetime import datetime, timedelta PLUGIN_ROOT = os.path.dirname(os.path.abspath(__file__)) +LIB_PATH = os.path.abspath(os.path.join(PLUGIN_ROOT, "..", "lib")) FFMPEG_SETUP_LOCK = os.path.join(PLUGIN_ROOT, "ffmpeg_setup.lock") URL_REGEX = ( "((http|https)://)(www.)?" @@ -280,3 +284,74 @@ def extract_ffmpeg(): return False, binaries_reason return True, None + + +def check_ytdlp_update_needed(check_interval_days=5): + """ + Check if yt-dlp library update is needed based on the last update timestamp. + + Args: + check_interval_days (int): Number of days between update checks. + + Returns: + bool: True if update is needed, False otherwise. + """ + + # Path to yt-dlp package in lib folder + lib_ytdlp_path = os.path.join(LIB_PATH, "yt_dlp") + update_marker = os.path.join(LIB_PATH, ".ytdlp_last_update") + + # If yt-dlp doesn't exist in lib, update is needed + if not os.path.exists(lib_ytdlp_path): + return True + + # Check the update marker file + if os.path.exists(update_marker): + try: + last_update = datetime.fromtimestamp(os.path.getmtime(update_marker)) + if datetime.now() - last_update < timedelta(days=check_interval_days): + return False + except Exception: + # If we can't read the marker, assume update is needed + return True + + return True + + +def update_ytdlp_library(): + """ + Update the bundled yt-dlp library in the lib folder. + + Returns: + tuple: (success: bool, message: str) + """ + + update_marker = os.path.join(LIB_PATH, ".ytdlp_last_update") + + # Try different pip commands in order of preference + pip_commands = [ + [sys.executable, "-m", "pip", "install", "--upgrade", "--target", LIB_PATH, "yt-dlp"], + ["python", "-m", "pip", "install", "--upgrade", "--target", LIB_PATH, "yt-dlp"], + ["pip", "install", "--upgrade", "--target", LIB_PATH, "yt-dlp"], + ] + + last_error = None + for cmd in pip_commands: + try: + subprocess.run(cmd, check=True, timeout=120) + + # Create/update the marker file + os.makedirs(LIB_PATH, exist_ok=True) + with open(update_marker, "w") as f: + f.write("updated") + + return True, "yt-dlp library updated successfully" + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + last_error = e + continue + + # All commands failed + if isinstance(last_error, subprocess.TimeoutExpired): + return False, "Update timed out after 2 minutes" + else: + return False, f"All pip commands failed. Last error: {str(last_error)}"