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).

Questions

Can it download Instagram videos? Yes. Send it a link to a public Instagram reel or video post and it replies with the video file. Stories and posts from private accounts need an Instagram login, so they don't work.

Which Telegram bot downloads videos from YouTube or TikTok links? This one: paste the link into a chat with @VideoDownloadBot. Files up to 2 GB come back as videos you can watch, save or forward, and a link the bot has seen before comes back instantly.

Is it free? Yes. There is no paid plan and no sign-up, and the code is open source under the MIT license. Please download only videos you have the right to save.

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). The bot updates yt-dlp to the newest nightly (yt-dlp --update-to nightly) before it starts polling and then daily. A failed update, such as GitHub's 403 once the address the server shares has used up its hourly API limit, is logged and tried again up to six times, 1, 2, 4, 8, 16 and 32 minutes apart (the last just over an hour after the first), while the bot goes on with the yt-dlp it has. A try that hits its 5-minute limit leaves only that last try, 32 minutes later, since every yt-dlp run waits for an update that's running (yt-dlp gives up on a server that doesn't answer within 20 s, so that takes a transfer that trickles or a stuck run). 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 (without either, guest downloads fail: a guest message the bot has answered hears so, while a reply that doesn't mention the bot still waiting for its answer gets none)
  • DATA_DIR: where bot.sqlite lives, default ./data (/data in Docker). The old and new containers share it during a deploy, and once it's up the bot doesn't wait for the other one's lock on it, since a wait would hold the whole bot still, so a statement that meets the lock fails at once. For the cache of sent files that costs a request a download at most: a row that can't be read counts as a miss and its link is downloaded again, a file that went out still reaches its chat and every request following its job when its row can't be written, and a dead file_id that can't be forgotten is downloaded again all the same
  • MAX_DOWNLOADS: videos downloaded and sent at once, default 8. With 4, the three long slots were all busy for 88–96% of a day's busiest six hours and long videos waited up to about 3 hours for one; the sites that fill them, adult CDNs at 0.1–1.3 MB/s, are slow per connection (Eporner kept its speed in five downloads at once), so more at once get more done. 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 (6 of 7 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. One that doesn't mention the bot and has no answer yet isn't answered "Downloading…" from then on, as its download couldn't be uploaded, nor told to send the link again, as a group message that didn't mention the bot isn't, and once the drain has begun it isn't answered just because its 30 s are up either (the exit could leave a page without a video showing "Downloading…" for good). 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 downloads from what its -J picked, that video's info alone: a post with several videos (an X post, a page with several <video>s) is a playlist of its first one there, and yt-dlp drops a playlist's entries from saved info, so it would read the page again itself and download whatever that picks. 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. That download goes on from what the first one got only when the page picks the same video and formats again: the same format ids and encodings (yt-dlp numbers the formats a site gives no id), each with the same exact size or, where yt-dlp doesn't say one, the same link. On a site yt-dlp has an extractor for, that's the link up to its query, where signatures that change with every read usually go. On a page it reads with its generic extractor, or with the WordPress, Elementor and Camtasia players it finds on one, the video's id isn't a site's own (it's the page's address, the file's name or the widget's) and its format has nothing but the link to tell another clip apart, so the whole link counts, query included, since a page may pick its clip by it. Otherwise it starts from nothing, because yt-dlp resumes a partial file of the name it's about to write, a .part by its bytes and a .ytdl by its fragment index, whatever format it came from, so another format would be appended to the first one's. A format of unknown size whose link changes its host or path between the reads (a CDN that answers from another server, or a signature in the path), or on a generic page its query, starts from nothing too, though it may be the same file, and an HLS download that yt-dlp hands to ffmpeg starts over either way. A post whose first entry is a playlist itself starts from nothing as well, since yt-dlp reads that one's page again whatever is saved. A link that stays the same while the file behind it changes can't be told apart. When the page read again picks another video (a multi-video post whose first one has changed, or a link that leads to another one now), that one gets the checks the first one got (a stream that can't be downloaded yet fails, a copy cached under its id goes out at once, and a size over the limit fails), then goes out with its own title, length and kind and is cached under its own id, never the first one's. A refresh of a video that went out without its size goes on only while the page picks that same video, the one whose id has the old copy cached, at the first read or when read again; otherwise it stops, and the chat and every link keep the old copy. 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, including other people's that wait only because their own jobs hold their part of the slots), own (how many of that person's jobs, long or short, had a slot or were in line for one then: with own=0 a wait was behind other people's links alone, while with more, part of it may have been behind their own, as in one person's burst of long links), 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, with kept or dropped: the MB the first download got, left for the same pick to resume or deleted for another, and other=1 when it picked another video, whose lane and dur are still the first one's), 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 128 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, 120 GB with the default. That's typical rather than guaranteed: sixteen worst-case jobs at once would fill 128 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). While another process holds the database's lock (a deploy's old container writing), a promo's stats never cost a reply its video or its help: a promo whose cap can't be read or recorded isn't shown that time, the events go out without their once-a-day marks, and an arrival that can't be recorded gets no use events. 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
  • WARP_PROXY: host:port of an HTTP CONNECT proxy that YouTube hosts (youtube.com, youtu.be, googlevideo.com, ytimg.com) go through instead of straight out, still after the egress checks; unset = straight out. YouTube blocks the server's address (#415), so prod points it at the videodownloadbot-warp service (the Dockerfile's warp stage: Cloudflare WARP via wireproxy). YouTube asks WARP addresses to sign in, so it needs the COOKIES session
  • 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. Telegram calls the bot there on any reply to one of its messages too, and a reply that doesn't mention the bot has its links checked as a group message's are: it gets no answer unless it has a link of its own to one of those sites, and even then only once yt-dlp has found a video there (as its download begins, or once its cached copy has gone out). A link the bot has a copy cached for is answered right away, as a video's (should that copy turn out to be gone and the page have no video any more, the reply then hears the failure). A page without a video on such a site (an article on a news site like the BBC, CNN or the NYT, or a tweet without a video) gets no answer at all, as in a group, while a link that fails for real (a video that's unavailable or too big, or a stream that's live or hasn't started) hears so, as it would in a group. Telegram doesn't keep a guest message open for an answer forever, though, and doesn't say for how long, so a reply still waiting 30 s after it was sent (for its turn, or on a slow page) is answered then with where its download is ("In the queue" or "Downloading…"), and from then on it hears what a mention would, the failure on a page without a video included. One that reaches the bot later than that (after an outage) is answered at once, and gets no download when Telegram no longer takes that answer. A mention is answered at once, before yt-dlp has read the page. The bot's own messages don't ask it for anything, so one of its videos forwarded into another chat it's in isn't posted there a second time. A forward of one of its messages (or a message sent through it) gets no answer at all, in private chats and in Guest Mode as well. So does a copy of one of its videos (forwarded with the sender's name hidden, or posted again by a client or another bot), which arrives as the sender's own message: the bot knows it by the file, which is the same in every chat, even when that copy is forwarded on. That works for every file it has sent since it began keeping track of them (#435), while a copy of a video it last sent before then counts as anyone's until that video goes out again. Mentioning the bot in reply to one of its messages, whether forwarded, copied or neither, doesn't download that message's link either.

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