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
2 changes: 1 addition & 1 deletion commands/content/toimg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 3 additions & 5 deletions commands/downloader/cancel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')}")
17 changes: 6 additions & 11 deletions commands/downloader/dl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -290,15 +288,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,
)
return 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}")

Expand Down
74 changes: 37 additions & 37 deletions core/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -422,8 +424,7 @@ def _flat_extract():
)

full_opts = {
"quiet": True,
"no_warnings": True,
**self._base_ydl_opts(),
"extract_flat": False,
"skip_download": True,
}
Expand All @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
}
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
19 changes: 15 additions & 4 deletions docs/commands/downloader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` or `/dl <query>`
Expand Down Expand Up @@ -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`.
3 changes: 2 additions & 1 deletion docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 4 additions & 1 deletion locales/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down