Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions plugin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
verify_ffmpeg_zip,
extract_ffmpeg,
get_binaries_paths,
check_ytdlp_update_needed,
update_ytdlp_library,
)
from results import (
init_results,
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 36 additions & 1 deletion plugin/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 75 additions & 0 deletions plugin/utils.py
Original file line number Diff line number Diff line change
@@ -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.)?"
Expand Down Expand Up @@ -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

Comment on lines 304 to 317
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic checks if the yt-dlp library doesn't exist in the lib folder and returns True to trigger an update. However, when the plugin is first installed via the build process, yt-dlp is installed to the lib folder. This check will always return False on first run (since the library exists from the build), meaning the automatic update feature won't work correctly for fresh installs until 5 days have passed without a marker file. Consider checking if the marker file exists first, and only fall back to checking the library path existence if the marker doesn't exist.

Suggested change
# 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:
pass
# Check the update marker file first
if os.path.exists(update_marker):
try:
last_update = datetime.fromtimestamp(os.path.getmtime(update_marker))
# If the last update is within the allowed interval and the library exists,
# no update is needed.
if datetime.now() - last_update < timedelta(days=check_interval_days):
if os.path.exists(lib_ytdlp_path):
return False
# If the marker is fresh but the library is missing, treat as needing update.
except Exception:
# Any issue reading/parsing the marker should fall through to requiring an update.
pass
# If there is no marker, or it's stale/invalid, or the library is missing,
# trigger an update so we can refresh the bundled yt-dlp and create/update the marker.

Copilot uses AI. Check for mistakes.
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"],
]
Comment on lines +333 to +336
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the lib folder is in sys.path (as seen in run.py), installing yt-dlp with --target to the lib folder can create version conflicts. Since sys.path has lib before the system site-packages, the updated version in lib will take precedence. However, if yt-dlp is already installed system-wide (as indicated by requirements.txt), you could end up with duplicate installations. Additionally, --target doesn't handle dependencies the same way as normal pip install, which could lead to missing or conflicting dependency versions. Consider using pip install --upgrade without --target to upgrade the system installation, or ensure the lib folder is the only source of yt-dlp packages.

Copilot uses AI. Check for mistakes.

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)}"
Loading