Skip to content

About

@VideoDownloadBot: a Telegram bot that downloads videos from YouTube, TikTok, Instagram, X and many more sites by link, up to 2 GB

Topics

Resources

Stars

140 stars

Watchers

4 watching

Forks

Repository files navigation

@VideoDownloadBot is a free Telegram bot that downloads videos. Send it a link from YouTube, TikTok, Instagram, X, VK, Twitch, Dailymotion, SoundCloud or one of many other sites and it sends the video back in the chat, up to 2 GB. Add it to a group, or mention it in any chat, and it works there too. Reddit, Vimeo and Facebook links need a login and don't work. Open source (MIT), built on yt-dlp.

How to use: open t.me/VideoDownloadBot, press Start, paste a link.

(videodownloadbot.com is a different bot, not this one.)

Website: videodl.borodutch.com (supported sites, how to use it in chats and groups, FAQ).

How it works

Send it a link (or post one in a group it's in) and it replies with the video. Downloads go through yt-dlp; every sent file's Telegram file_id is kept in SQLite, so the same link (or the same video under another link) is resent instantly. When Telegram's flood control holds the bot back (a 429, mostly in busy groups), it waits as long as Telegram asks and sends again, up to 5 times and 2 minutes in all, and until the flood is over its messages to that chat go one at a time (the "Downloading…"/"Uploading…" status is skipped rather than waited for), so a finished video is delayed rather than lost. Originally developed by MaxiFilippov.

Run

bun install
TOKEN=123:abc bun bot.ts

Needs yt-dlp and ffmpeg on PATH (the Dockerfile ships both). bun test runs the checks.

Environment

  • TOKEN: Telegram bot token
  • BOT_API_URL: Bot API server, default https://api.telegram.org. Point it at a local Bot API server (--local) to upload up to 2000 MB instead of 50 MB
  • COOKIES: Netscape cookies file for yt-dlp (a logged-in YouTube session), default /cookies.txt, skipped if missing. It seeds DATA_DIR/cookies.txt, which keeps the cookies yt-dlp refreshes; replacing COOKIES reseeds it. Every 6 h the bot opens Watch Later to keep the session in use and checks it's still logged in
  • ADMIN_ID: Telegram user ID that gets a DM when the YouTube login dies
  • STORAGE_CHAT: chat the bot uploads guest-mode videos to before putting them into its guest reply (each upload is deleted right away), default ADMIN_ID (guest replies fail without either)
  • DATA_DIR: where bot.sqlite lives, default ./data (/data in Docker)
  • MAX_DOWNLOADS: videos downloaded and sent at once, default 4. Long ones (over 10 minutes; some sites take 20–40 minutes for them) get at most MAX_DOWNLOADS − 1 of these slots (the one slot when it's 1), so long downloads never hold every slot. A video that doesn't say how long it is counts as long too, since an HLS/DASH stream or a file of unknown length can take as long, unless it's from Instagram or an X post (not a broadcast or a Space), whose reels and GIFs usually don't say and download in seconds, or yt-dlp says what all of it weighs and that's 100 MB at most. One person's jobs get at most MAX_DOWNLOADS − 1 slots in all (the one slot when it's 1) and, when long videos have more than one slot, all but one of those (2 of 3 with the default), so with three slots or more another person's link, long or short, doesn't wait for theirs; a free slot goes to whoever has the fewest jobs running, and a job waiting for a slot holds none. A link that has to wait for a -J turn or a slot gets a "queued" status in private chats, and in groups when the bot was mentioned (elsewhere a burst of links would turn into a burst of replies against the group's message limit); a second request for the same link follows the job's status where a queued one would show: whatever the job is at when it comes in (queued, downloading or uploading), then each change. On a deploy, the links still waiting in the old container (for -J or for a slot), and long videos whose -J ends then, are told to send the link again (where they would hear they are queued); a short video that finds a free slot still goes. Jobs still waiting or downloading 2 s before the old container exits (the drain waits 15 s) won't finish there: their chats are told the same, where they already show a status or would hear they are queued (skipped, like any status, while the chat is in a flood), rather than keep "Downloading…" (one that still gets a cached copy of its file before the exit has the notice deleted). From then on no job begins an upload, so a download that ends in those 2 s stops there instead of sending its video next to the notice. A job whose upload has begun by then keeps its status, since a local Bot API server finishes an upload by path after the container exits (a second request following it is still told, as its own copy would go out only after the job's), while one that already shows "Uploading…" without having begun it is told like the rest. A request whose final answer is already on its way keeps that answer, and a chat that already got its file while a refresh of an unsized video ran hears nothing (a refresh that hasn't begun its upload doesn't begin it either). A guest-mode reply isn't told at that point and keeps its status, unless its job gets to the upload after it, before the exit: then it hears to send the link again as well. yt-dlp's -J, which tells how long the video is, runs before the job takes a slot (up to MAX_DOWNLOADS at once, one person's up to MAX_DOWNLOADS − 1). Once it has its slot, a job sends the cached file if another link to the same video was downloaded meanwhile. A job that waited over a minute downloads from the -J info first, and reads the page again if that fails (unless it ran out of time) or a fragment is missing, since media links can expire. A job may spend 30 minutes downloading, a long video's job 45, a page read again included (it's read again only with over a minute of that left): some sites serve one connection at about 1–1.3 MB/s, where the biggest file a local Bot API takes can need about 35 minutes. The limit stops yt-dlp's whole process group, so it also stops the ffmpeg that yt-dlp hands m3u8 and some HLS downloads to: the group gets SIGTERM, then SIGKILL as soon as yt-dlp's output closes or 5 s have passed, which also ends whatever ignored the SIGTERM (a merge's ffmpeg doesn't hold that output), and after those 5 s the job ends even if something outside the group still holds it. A download also stops, the same way, once one of its files is bigger than the Bot API server takes (2000 MB, or 50 MB with the cloud one), weighed every 5 s: yt-dlp's --max-filesize only works when the server says how big a file is, which HLS and DASH downloads of unknown size (stream recordings) don't, so they would go on until the time limit. The chat hears the video is too large (fail:tooBig), and the page isn't read again. It's the download as it comes that's weighed, so an MPEG-TS (HLS) one just over the limit stops too, though remuxing it into MP4 would take a few % off. Every job logs a job line: the result (ok, cached, fail:<reason>, timeout when yt-dlp hit that limit, or error), host, metaq and meta (seconds waiting for and running -J), lane (short or long), ahead (how many jobs were in line for a slot when it asked for one), dur (the video's length in seconds, ? when it doesn't say), wait (seconds for a slot), dl (seconds downloading, a failed download's too; reread=1 when the page was read again), mb (the file's size, or the MB a failed download got), up (seconds for probe, thumbnail and upload) and total (seconds)
  • SHARED_DIR: a directory the local Bot API server mounts at the same path, for the downloads instead of the container's /tmp; files then upload by path (file://) rather than as a second copy over HTTP. On hetzner-main it's a 64 GB tmpfs (/srv/scratch/videodownloadbot, the systemd unit in deploy/, mounted at /scratch in both containers, where the Bot API server's TELEGRAM_TEMP_DIR points too), so videos never touch the disk the databases share. yt-dlp unpacks itself there too (TMPDIR, ~85 MB a run), so the mount can't be noexec. Old and new containers overlap during a deploy, and when yt-dlp reports no sizes, a job can hold a 2000 MB video part, a 2000 MB audio part and their merge before the size check, about 8 GB. HLS and DASH downloads of unknown size are held to that too: the bot stops one once a file of it passes 2000 MB, where a long stream recording at 5 MB/s would otherwise write about 13 GB in its 45 minutes. Each file can still get past 2000 MB by what yt-dlp downloads in the 5 s between two weighings and in the 5 s at most it takes to stop. Short jobs (10 minutes at most, from Instagram or X, or 100 MB at most) stay far below that in practice, so size the mount above about 2 × (max(1, MAX_DOWNLOADS − 1) × 8 + 4) GB, 56 GB with the default. That's typical rather than guaranteed: eight worst-case jobs at once would fill 64 GB. Each container works in its own bot-<hostname> folder there and sweeps the folders of dead ones after 10 minutes. Mount the tmpfs on the host before (re)creating both containers: a Docker bind made earlier still shows the folder underneath, and the Bot API server then can't find the files. Ignored with the cloud Bot API
  • STATS_PORT: port of the stats endpoint, default 8080. GET /stats returns chat and user totals and new ones per UTC day (counted from when the bot first sees them; ids only, nothing else is stored) for stats.borodutch.com. Don't give it a public domain
  • PROMO_TOKEN: turns on cross-promotion experiments (promo.ts): campaigns come from PROMO_URL (default https://lumen.borodutch.com/go/campaigns.json, edited in Prime Radiant's go/campaigns.json), and the token signs the seen/shown events and salts the user hashes in them. Unset, or PROMO=off, means no promos at all. Promos only go into private-chat replies, at most one per reply and one per person per campaign's cap_days, and every person stays in the same arm (a variant or the holdout, which never sees one). Links to another bot are native deep links (t.me/<bot>?start=<campaign>-<variant>-vdb, which Telegram opens in the app; the promoted bot reports the /start), channel invite links go as they are, and only web pages go through the tracking redirect
  • EGRESS_DENY: comma-separated addresses or CIDRs (IPv4 and IPv6) that yt-dlp may never reach, on top of the private, loopback and link-local ranges it's always kept out of. Set it on deploy to the server's own public IPv4 address and IPv6 prefix (e.g. 1.2.3.4/32,2a01:4f8:c0c:1::/64). Those addresses look public, but they lead to every port docker publishes on the host (the panel, the reverse proxy's internal routes), and a cloud firewall doesn't filter traffic that starts on the host. If docker gives containers global IPv6 addresses from a routed prefix outside the host's /64, list that prefix too. A malformed entry stops the bot at startup

In groups the bot needs privacy mode off (BotFather → Bot Settings → Group Privacy) or admin rights to see links. There it only picks up links to sites yt-dlp has a dedicated extractor for (YouTube, TikTok, Instagram, X and so on) and stays quiet when such a page has no video; other links, like a bare domain or an article, are ignored unless the bot is mentioned. Replying to a message with a link and mentioning the bot (@VideoDownloadBot) downloads the replied-to link. With Guest Mode on (BotFather Mini App → bot settings), the same mention works in any chat, even ones the bot isn't in.

License

MIT

About

@VideoDownloadBot: a Telegram bot that downloads videos from YouTube, TikTok, Instagram, X and many more sites by link, up to 2 GB

Topics

Resources

Stars

140 stars

Watchers

4 watching

Forks

Sponsor this project

Used by

Contributors

Languages