@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).
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.
bun install
TOKEN=123:abc bun bot.tsNeeds yt-dlp and ffmpeg on PATH (the Dockerfile ships both). bun test runs the checks.
TOKEN: Telegram bot tokenBOT_API_URL: Bot API server, defaulthttps://api.telegram.org. Point it at a local Bot API server (--local) to upload up to 2000 MB instead of 50 MBCOOKIES: Netscape cookies file for yt-dlp (a logged-in YouTube session), default/cookies.txt, skipped if missing. It seedsDATA_DIR/cookies.txt, which keeps the cookies yt-dlp refreshes; replacingCOOKIESreseeds it. Every 6 h the bot opens Watch Later to keep the session in use and checks it's still logged inADMIN_ID: Telegram user ID that gets a DM when the YouTube login diesSTORAGE_CHAT: chat the bot uploads guest-mode videos to before putting them into its guest reply (each upload is deleted right away), defaultADMIN_ID(guest replies fail without either)DATA_DIR: wherebot.sqlitelives, default./data(/datain 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 mostMAX_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 mostMAX_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-Jturn 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-Jor for a slot), and long videos whose-Jends 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 toMAX_DOWNLOADSat once, one person's up toMAX_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-Jinfo 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-filesizeonly 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 ajobline: the result (ok,cached,fail:<reason>,timeoutwhen yt-dlp hit that limit, orerror),host,metaqandmeta(seconds waiting for and running-J),lane(shortorlong),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=1when the page was read again),mb(the file's size, or the MB a failed download got),up(seconds for probe, thumbnail and upload) andtotal(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 indeploy/, mounted at/scratchin both containers, where the Bot API server'sTELEGRAM_TEMP_DIRpoints 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 benoexec. 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 ownbot-<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 APISTATS_PORT: port of the stats endpoint, default 8080.GET /statsreturns 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 domainPROMO_TOKEN: turns on cross-promotion experiments (promo.ts): campaigns come fromPROMO_URL(defaulthttps://lumen.borodutch.com/go/campaigns.json, edited in Prime Radiant'sgo/campaigns.json), and the token signs the seen/shown events and salts the user hashes in them. Unset, orPROMO=off, means no promos at all. Promos only go into private-chat replies, at most one per reply and one per person per campaign'scap_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 redirectEGRESS_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.
MIT