A self-hosted Telegram bot that turns YouTube links into tagged audio files with embedded album art. Send it a link, get back an M4A (or MP3, or the video).
Runs in Docker. Designed to keep working when YouTube changes its extraction defences — which it does every few months.
you ▸ https://youtu.be/dQw4w9WgXcQ
bot ▸ ⏳ Fetching audio… ██████░░░░ 62%
bot ▸ 🎵 [audio file, tagged, with cover art]
- Features
- Requirements
- Quick start
- Configuration
- Commands
- Large files (>50 MB)
- How it works
- Keeping it working
- Troubleshooting
- Security notes
- Optional extras
- M4A by default — copied straight from YouTube's audio stream, no re-encode,
so it's faster and smaller than transcoding to MP3.
/mp3if you want MP3. - Video downloads —
/video <url>, up to 720p. If the result is too big to send, it's automatically downscaled (480p → 360p → 240p) until it fits. - Clipping — append timestamps to grab a section:
<url> 1:23 4:56. - Playlists / albums — up to 50 videos, with a confirmation step first.
- Metadata + cover art — ID3 / MP4 tags with title, artist, album and embedded artwork, so files land correctly in Apple Music and friends.
- Queue — up to 5 concurrent downloads, with live position and wait estimates when the queue is busy.
- Caching — a video already fetched is re-sent instantly. Local cache with a 24-hour TTL and automatic sweeping of stale fragments.
- Access control — allowlist, admin roles, per-user rate limiting.
- Operations — health checks, maintenance mode, structured logs, admin error alerts, and a watchdog that kills hung jobs instead of leaving users staring at a spinner.
- Docker and Docker Compose
- ~2 GB RAM and a few GB of disk for the cache
- A Telegram bot token from @BotFather
Everything else — yt-dlp, ffmpeg, Deno, the PO-token provider — is installed
inside the image. Nothing to set up on the host.
git clone <your-fork-url> mp3-downloader-bot
cd mp3-downloader-bot
cp .env.example .env
# Edit .env: set TELEGRAM_BOT_TOKEN, and set ADMIN_USER_IDS to your own
# Telegram user ID (get it from @userinfobot).
# The compose file bind-mounts this file. It must exist, even if empty —
# otherwise Docker helpfully creates a *directory* with that name and yt-dlp
# will fail in a confusing way. See "Cookies" below for when to fill it in.
touch cookies.txt
docker compose up -d --build
docker compose logs -f yt-botThen message your bot on Telegram and send it a YouTube link.
Set
ALLOWED_USER_IDSbefore you share the bot's username. If it's empty the bot is open to anyone who finds it, and you'll be running a public YouTube ripper on your own bandwidth and IP address.
All configuration is environment variables, read from .env. See
.env.example for the annotated full list.
| Variable | Description |
|---|---|
TELEGRAM_BOT_TOKEN |
Bot token from @BotFather |
| Variable | Default | Description |
|---|---|---|
ADMIN_USER_IDS |
(empty) | Comma-separated user IDs with admin rights. Admins bypass the allowlist and maintenance mode, and receive error alerts. |
ALLOWED_USER_IDS |
(empty) | Comma-separated user IDs permitted to use the bot. Empty means everyone. |
RATE_LIMIT_REQUESTS |
10 |
Requests allowed per user per period |
RATE_LIMIT_PERIOD |
3600 |
Period in seconds |
MAINTENANCE_MODE |
false |
When true, only admins can use the bot |
Find your numeric Telegram user ID with @userinfobot.
(The bot's own /userid command is admin-only, so it can't help you bootstrap
the first admin.)
| Variable | Default | Description |
|---|---|---|
TELEGRAM_LOCAL_API_URL |
(empty) | Set to http://yt-bot-api:8081 to use the local Bot API sidecar. See Large files. |
TELEGRAM_API_ID / TELEGRAM_API_HASH |
(empty) | Required only for the local Bot API sidecar. From my.telegram.org/apps. |
MAX_UPLOAD_MB |
48 / 1950 |
Upload ceiling. Defaults to 48 on the public API, 1950 with the local one. |
PIPELINE_TIMEOUT_AUDIO |
300 |
Seconds before a stuck audio job is killed |
PIPELINE_TIMEOUT_VIDEO |
900 |
Seconds before a stuck video job is killed |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR |
| Command | Who | What |
|---|---|---|
| (just send a link) | everyone | Audio as M4A |
/mp3 <url> |
everyone | Audio as MP3 |
/m4a <url>, /opus <url> |
everyone | Audio as M4A (explicit) |
/video <url> |
everyone | Video file, up to 720p |
/start |
everyone | Welcome message and feature list |
/status |
admin | Cache size, active downloads, queue depth, request stats |
/maintenance |
admin | Toggle maintenance mode |
/userid |
admin | Show your user ID |
Clipping. Every command above accepts trailing timestamps to cut a section:
https://youtu.be/VIDEO_ID 1:23 4:56
https://youtu.be/VIDEO_ID 1:23-4:56
/video https://youtu.be/VIDEO_ID 0:30 1:00
Playlists. Send a playlist URL and the bot shows a confirmation with the track count before downloading. Playlists are capped at 50 videos; longer ones are truncated.
Telegram's public Bot API caps bot uploads at 50 MB. The bot handles this in two ways.
By default, when a file exceeds the cap, the bot offers a choice: one compressed file that fits, or the original quality split into several parts.
Or run your own Bot API server, which raises the ceiling to ~2 GB. It's included as an opt-in Compose profile:
- Get
TELEGRAM_API_IDandTELEGRAM_API_HASHfrom my.telegram.org/apps. - Add to
.env:TELEGRAM_API_ID=... TELEGRAM_API_HASH=... TELEGRAM_LOCAL_API_URL=http://yt-bot-api:8081 - Detach the bot from Telegram's cloud servers — a one-time, irreversible-ish
step, so read the upstream note first:
curl -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/logOut" - Start with the profile enabled:
docker compose --profile local-api up -d --build
To go back to the cloud API, call logOut against the local server, then stop
the profile.
Three containers:
| Service | Role |
|---|---|
yt-bot |
The bot itself. Python, python-telegram-bot, yt-dlp, ffmpeg. |
bgutil-provider |
Mints Proof-of-Origin tokens. YouTube returns 403 on stream URLs without one. Internal network only, no published ports. |
telegram-bot-api |
Optional local Bot API server (local-api profile only). |
A request flows: URL parsed and validated → cache checked → queued if all five
download slots are busy → yt-dlp fetches the stream → ffmpeg converts or
clips if needed → tags and cover art embedded → uploaded to Telegram → cached.
On YouTube's defences. Extraction is the fragile part of any tool like this, and two things in the image exist purely to keep it working:
- Deno is bundled as
yt-dlp's JavaScript runtime. YouTube obfuscates stream URLs behind a JS challenge; without a runtime to solve it, downloads come back throttled or 403. - PO tokens from the
bgutil-providersidecar, wired up through/etc/yt-dlp.conf.
yt-dlp.conf also pins the player client to web_embedded, which as of this
writing is the one that reliably serves plain-HTTPS stream URLs. The trade-off:
videos with embedding disabled can't be downloaded through it. Both of these are
moving targets — expect to revisit them when YouTube next changes something.
cookies.txt (Netscape cookie-jar format) is bind-mounted into the container and
passed to yt-dlp if it's non-empty. You only need it for age-restricted or
otherwise login-gated videos.
Use a throwaway Google account. The file is a live session for whatever
account you export it from — anyone who gets it is logged in as you. It's in
.gitignore for that reason; keep it that way.
yt-dlp is frozen into the image at build time, and YouTube breaks older
versions every few months. The fix is to rebuild on a schedule, not to wait for
it to break.
scripts/rebuild-image does this safely: pulls main,
rebuilds with --no-cache, waits for the container to report healthy, then runs
an actual download as a smoke test — because the health check validates
dependencies and connectivity, not extraction, and passes happily while
downloads are broken. Only once both gates pass does it prune old layers, so
until then the previous image is still there to roll back to.
Install it as a monthly cron job:
30 4 1 * * /path/to/yt-bot/scripts/rebuild-image >> /var/log/yt-bot-rebuild.log 2>&1For a quick fix without a rebuild, scripts/update-yt-dlp.sh
upgrades yt-dlp inside the running container. It's a stopgap — the change is
lost on the next restart.
docker compose logs -f yt-bot # follow logs
docker compose exec yt-bot python healthcheck.py # env, deps, API, cache, disk
docker compose exec yt-bot yt-dlp --version # what version is actually running| Symptom | Likely cause |
|---|---|
| Every download fails with 403 or "unable to extract" | YouTube changed something. Rebuild the image (scripts/rebuild-image) to pick up a current yt-dlp. |
| One specific video fails, others work | Often embedding-disabled — the web_embedded player client can't reach it. |
| "File must be non-empty" on send | The local Bot API server can't see the cache volume. Check that both containers mount yt-bot-cache. |
| Bot starts then immediately exits | Usually a missing TELEGRAM_BOT_TOKEN. The logs say which variable. |
cookies.txt errors, or it appears as a folder |
Docker created a directory because the file didn't exist. rm -rf cookies.txt && touch cookies.txt, then recreate the container. |
| Cache growing without bound | Sweeps run automatically, but docker compose exec yt-bot find /app/cache -name "*.mp3" -mtime +7 -delete clears it out by hand. |
- Runs as non-root. The entrypoint fixes volume permissions as root, then
drops to the unprivileged
ytbotuser viagosu.no-new-privilegesis set. - Input validation. URLs are matched against strict patterns and reduced to an 11-character video ID; every cache path is built from that ID, so a crafted title or URL can't escape the cache directory.
- No shell interpolation. Every
yt-dlpandffmpegcall usescreate_subprocess_execwith an argument list — noshell=Trueanywhere. - Secrets stay in
.env, which is gitignored, as arecookies.txtand the Bot API session data. - The bot is as open as you leave it.
ALLOWED_USER_IDSis the only thing standing between your server and the public; rate limiting is per-user and won't save you from a crowd.
Not required to run the bot:
scripts/s3-sync— standalone Bash utility to sync the cache to an S3-compatible bucket (Cloudflare R2), with hand-rolled SigV4 signing. The bot does not call it; wire it into cron yourself if you want off-box backups.scripts/creds— small GPG helper to keep an encrypted copy of your.env(creds encrypt/creds decrypt). If you use it, keep the resulting.gpgfile out of a public repo: it's a passphrase-encrypted blob, and publishing one invites offline cracking.
Built with yt-dlp, python-telegram-bot, and ffmpeg.
Licensed under the MIT License.
You are responsible for respecting YouTube's Terms of Service and copyright law wherever you run this.