From e7363bf119b6e015447a2c0e8a69acec25c26a69 Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Sat, 21 Feb 2026 10:25:41 +0700 Subject: [PATCH 1/3] fix(downloader): YT JS challenge, DRY opts, fix toimg quote --- commands/content/toimg.py | 2 +- commands/downloader/cancel.py | 8 ++- commands/downloader/dl.py | 15 +++--- core/downloader.py | 74 ++++++++++++++-------------- docs/commands/downloader.md | 19 +++++-- docs/getting-started/installation.md | 3 +- locales/en.json | 5 +- locales/id.json | 5 +- 8 files changed, 72 insertions(+), 59 deletions(-) diff --git a/commands/content/toimg.py b/commands/content/toimg.py index 95c0e12..0434d18 100644 --- a/commands/content/toimg.py +++ b/commands/content/toimg.py @@ -59,7 +59,7 @@ async def execute(self, ctx: CommandContext) -> None: to=ctx.message.chat_jid, file=png_bytes, caption="", - quoted=ctx.message, + quoted=ctx.message.event, ) except Exception as e: diff --git a/commands/downloader/cancel.py b/commands/downloader/cancel.py index c2c09d1..a08f177 100644 --- a/commands/downloader/cancel.py +++ b/commands/downloader/cancel.py @@ -30,16 +30,14 @@ async def execute(self, ctx: CommandContext) -> None: count = downloader.cancel_all_in_chat(chat_jid) if count > 0: await ctx.client.reply( - ctx.message, f"{sym.SUCCESS} Cancelled {count} active download(s)." + ctx.message, f"{sym.SUCCESS} {t('downloader.cancel_all_success', count=count)}" ) else: - await ctx.client.reply(ctx.message, f"{sym.INFO} No active downloads in this chat.") + await ctx.client.reply(ctx.message, f"{sym.INFO} {t('downloader.cancel_all_none')}") return cancelled = downloader.cancel_download(chat_jid, sender_jid) if cancelled: await ctx.client.reply(ctx.message, f"{sym.SUCCESS} {t('downloader.cancelled')}") else: - await ctx.client.reply( - ctx.message, f"{sym.INFO} You don't have any active downloads in this chat." - ) + await ctx.client.reply(ctx.message, f"{sym.INFO} {t('downloader.cancel_none')}") diff --git a/commands/downloader/dl.py b/commands/downloader/dl.py index 1b0a7b9..b836bee 100644 --- a/commands/downloader/dl.py +++ b/commands/downloader/dl.py @@ -290,15 +290,12 @@ async def _show_options(self, ctx: CommandContext, info): if info.thumbnail: try: - async with httpx.AsyncClient(timeout=5) as http: - resp = await http.get(info.thumbnail) - if resp.status_code == 200 and len(resp.content) > 0: - return await ctx.client.send_image( - ctx.message.chat_jid, - resp.content, - caption=text, - quoted=ctx.message.event, - ) + await ctx.client.send_image( + ctx.message.chat_jid, + info.thumbnail, + caption=text, + quoted=ctx.message.event, + ) except Exception as e: log_warning(f"Failed to send thumbnail: {e}") diff --git a/core/downloader.py b/core/downloader.py index f7490ef..ccd0651 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -8,12 +8,15 @@ from __future__ import annotations import asyncio +import glob import os import shutil import time from dataclasses import dataclass, field from pathlib import Path +import yt_dlp + from core.constants import DOWNLOADS_DIR from core.logger import log_debug, log_error, log_info, log_warning from core.runtime_config import runtime_config @@ -160,6 +163,18 @@ def _add_cookies(self, ydl_opts: dict) -> None: else: log_debug(f"[DOWNLOADER] Cookie file not found at: {cookies_path}") + @staticmethod + def _base_ydl_opts() -> dict: + # https://github.com/yt-dlp/yt-dlp/issues/15814 + # https://github.com/yt-dlp/yt-dlp/issues/15012 + return { + "quiet": True, + "no_warnings": True, + "extractor_args": {"youtube": {"player_js_variant": ["tv"]}}, + "remote_components": "ejs:github", + "js_runtimes": {"bun": {}}, + } + @staticmethod def _parse_formats(raw_formats: list[dict]) -> list[FormatOption]: """Parse yt-dlp format list into clean FormatOption objects.""" @@ -271,11 +286,8 @@ async def search_youtube(self, query: str, count: int = 5) -> list[dict]: Returns: List of dicts with title, url, duration, uploader """ - import yt_dlp - ydl_opts = { - "quiet": True, - "no_warnings": True, + **self._base_ydl_opts(), "extract_flat": True, "skip_download": True, } @@ -329,11 +341,8 @@ async def get_playlist_info(self, url: str, max_entries: int = 25) -> PlaylistIn Returns: PlaylistInfo with title, count, and entry list """ - import yt_dlp - ydl_opts = { - "quiet": True, - "no_warnings": True, + **self._base_ydl_opts(), "extract_flat": True, "skip_download": True, "ignoreerrors": True, @@ -385,18 +394,11 @@ def _extract(): async def get_info(self, url: str) -> MediaInfo: """Extract media info without downloading.""" - import yt_dlp - flat_opts = { - "quiet": True, - "no_warnings": True, + **self._base_ydl_opts(), "extract_flat": True, "skip_download": True, "ignoreerrors": True, - "remote-components": "ejs:github", - "extractor_args": { - "youtube": {"player_js_variant": "tv"} - }, # https://github.com/yt-dlp/yt-dlp/issues/15814 } self._add_cookies(flat_opts) @@ -422,8 +424,7 @@ def _flat_extract(): ) full_opts = { - "quiet": True, - "no_warnings": True, + **self._base_ydl_opts(), "extract_flat": False, "skip_download": True, } @@ -448,13 +449,23 @@ def _full_extract(): actual_url = info.get("webpage_url", url) + thumbnail = info.get("thumbnail", "") + thumbnails = info.get("thumbnails") or [] + for t in reversed(thumbnails): + t_url = t.get("url", "") + t_ext = t.get("ext", "") + is_webp = t_ext == "webp" or t_url.lower().endswith(".webp") + if t_url and not is_webp: + thumbnail = t_url + break + return MediaInfo( title=info.get("title", "Unknown"), duration=info.get("duration", 0) or 0, uploader=info.get("uploader", info.get("channel", "Unknown")), platform=info.get("extractor_key", info.get("extractor", "Unknown")), url=actual_url, - thumbnail=info.get("thumbnail", ""), + thumbnail=thumbnail, filesize_approx=info.get("filesize_approx", 0) or info.get("filesize", 0) or 0, formats=format_options, ) @@ -489,15 +500,10 @@ async def download_format( fmt = format_id ydl_opts = { + **self._base_ydl_opts(), "format": fmt, "outtmpl": output_template, - "quiet": True, - "no_warnings": True, "merge_output_format": "mp4", - "extractor_args": { - "youtube": {"player_client": ["ios", "android", "mweb", "tv", "web"]} - }, - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", } if limit: @@ -541,21 +547,20 @@ async def download_audio( output_template = self._make_output_path("audio") ydl_opts = { + **self._base_ydl_opts(), "format": f"bestaudio[filesize<=?{int(limit)}M]/bestaudio/best", "outtmpl": output_template, - "quiet": True, - "no_warnings": True, "writethumbnail": True, - "extractor_args": { - "youtube": {"player_client": ["ios", "android", "mweb", "tv", "web"]} - }, - "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "postprocessors": [ { "key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192", }, + { + "key": "FFmpegThumbnailsConvertor", + "format": "jpg", + }, { "key": "FFmpegMetadata", "add_metadata": True, @@ -586,10 +591,9 @@ async def download_video( output_template = self._make_output_path("video") ydl_opts = { + **self._base_ydl_opts(), "format": f"(bestvideo[ext=mp4][filesize<=?{int(limit)}M]+bestaudio[ext=m4a]/best[ext=mp4][filesize<=?{int(limit)}M]/bestvideo+bestaudio/best)", "outtmpl": output_template, - "quiet": True, - "no_warnings": True, "merge_output_format": "mp4", "max_filesize": int(limit * 1024 * 1024), } @@ -607,8 +611,6 @@ async def _download( sender_jid: str | None = None, ) -> Path: """Internal download method.""" - import yt_dlp - downloaded_file = None dl_key = f"{chat_jid}:{sender_jid}" if chat_jid and sender_jid else None @@ -665,8 +667,6 @@ def _run(): downloaded_file = filename async def _cleanup_partial(): - import glob - if base_output and base_output != "None": for f in glob.glob(f"{base_output}*"): try: diff --git a/docs/commands/downloader.md b/docs/commands/downloader.md index 5f2a91b..fd4b70c 100644 --- a/docs/commands/downloader.md +++ b/docs/commands/downloader.md @@ -2,6 +2,14 @@ Zero Ichi includes a powerful media downloader powered by **yt-dlp**, supporting 1000+ sites including YouTube, TikTok, Instagram, Twitter/X, SoundCloud, and more. +::: warning YouTube Requirement +**[Bun](https://bun.sh)** must be installed on your server for YouTube downloads to work. yt-dlp uses it to solve YouTube's JS challenges. + +```bash +curl -fsSL https://bun.sh/install | bash +``` +::: + ## Commands ### `/dl ` or `/dl ` @@ -104,10 +112,13 @@ yt-dlp supports **1000+ sites** including: ## Troubleshooting -### "Sign in to confirm you're not a bot" +### "Sign in to confirm you're not a bot" / YouTube captcha This is common on VPS environments. To resolve it: -1. **Use Cookies**: Follow the [Cookie Setup Guide](/getting-started/configuration#youtube-cookies). -2. **EJS Support**: The bot automatically enables **External JS Scripts (EJS)** and tries multiple player clients (`web`, `android`, `tv`) to bypass challenges. -3. **Deno/Node**: Ensure you have `Deno` or `Node.js` installed on your VPS, as `yt-dlp` uses them to solve YouTube's JavaScript challenges. +1. **Install Bun**: yt-dlp uses Bun to execute YouTube's JS challenges: + ```bash + curl -fsSL https://bun.sh/install | bash + ``` +2. **Use Cookies**: Follow the [Cookie Setup Guide](/getting-started/configuration#youtube-cookies) to provide browser cookies. +3. **Verify Bun is in PATH**: After installing, make sure `bun --version` works in your shell. You may need to restart your shell or add `~/.bun/bin` to `$PATH`. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ef707da..edc1d95 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -2,9 +2,10 @@ ## Requirements -- **FFmpeg** - **Python 3.11+** - **[uv](https://github.com/astral-sh/uv)** — fast Python package manager +- **FFmpeg** — required for audio/video processing +- **[Bun](https://bun.sh)** — required for YouTube JS challenge solving (yt-dlp) - **Node.js 20+** — for the web dashboard (optional) ## Quick Start diff --git a/locales/en.json b/locales/en.json index d5a86e9..84a414f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -473,7 +473,10 @@ "search_results": "Results for: {query}", "no_results": "No results found for *{query}*.", "choose_result_hint": "Reply with the number to select a video.", - "cancelled": "Download cancelled." + "cancelled": "Download cancelled.", + "cancel_all_success": "Cancelled {count} active download(s).", + "cancel_all_none": "No active downloads in this chat.", + "cancel_none": "You don't have any active downloads in this chat." }, "toimg": { "reply_to_sticker": "Reply to a sticker to convert it to an image.", diff --git a/locales/id.json b/locales/id.json index c55d193..d163f08 100644 --- a/locales/id.json +++ b/locales/id.json @@ -473,7 +473,10 @@ "search_results": "Hasil untuk: {query}", "no_results": "Gak ketemu hasil buat *{query}*.", "choose_result_hint": "Balas dengan nomor untuk milih video.", - "cancelled": "Download dibatalkan." + "cancelled": "Download dibatalkan.", + "cancel_all_success": "{count} download aktif dibatalin.", + "cancel_all_none": "Gak ada download aktif di chat ini.", + "cancel_none": "Lu gak punya download aktif di chat ini." }, "toimg": { "reply_to_sticker": "Reply ke stiker buat convert jadi gambar.", From d5897e617a56f6dba4049ee4eac07aaf688fa993 Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Sat, 21 Feb 2026 11:10:59 +0700 Subject: [PATCH 2/3] fix(download) forgot to add return --- commands/downloader/dl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/downloader/dl.py b/commands/downloader/dl.py index b836bee..6f5baa8 100644 --- a/commands/downloader/dl.py +++ b/commands/downloader/dl.py @@ -290,7 +290,7 @@ async def _show_options(self, ctx: CommandContext, info): if info.thumbnail: try: - await ctx.client.send_image( + return await ctx.client.send_image( ctx.message.chat_jid, info.thumbnail, caption=text, From 0b26b00c583cf8c4f35783c38518acf93f04d417 Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Sat, 21 Feb 2026 11:11:15 +0700 Subject: [PATCH 3/3] fix: unused import --- commands/downloader/dl.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/commands/downloader/dl.py b/commands/downloader/dl.py index 6f5baa8..5c39e85 100644 --- a/commands/downloader/dl.py +++ b/commands/downloader/dl.py @@ -15,8 +15,6 @@ import re -import httpx - from core import symbols as sym from core.command import Command, CommandContext from core.downloader import DownloadAbortedError, DownloadError, FileTooLargeError, downloader