All notable changes to tg-streaming-bot are documented here. The format is based on Keep a Changelog, and the project aims to follow Semantic Versioning.
- Nix flake.
nix run github:antlis/tg-streaming-bot;.#with-browseradds the headless-Chromium fallback;nix developfor a dev shell. Buildskurigram,tgcrypto,py-tgcallsand the nativentgcallsfrom PyPI wheels (none are in nixpkgs), wrapsyt-dlp/ffmpeg/gitontoPATH, and runs the bot from a writable state dir (~/.local/state/tg-streaming-bot) because it keepsdownloads/andsearch/relative to the working directory. A home-manager module (services.tg-streaming-bot) runs it as a user service; optional VA-API viawithVaapi. CI runsnix flake check(byte-compile, lint, import every module).
- Extractor plugins (
PLUGIN_DIR,ENABLE_PLUGINS): drop.pyfiles exposingmatch()+resolve()into a directory and they resolve non-YouTube pages yt-dlp can't handle, before yt-dlp runs. Same contract as tg-media-bot / tg-mpv-bot; seeexamples/plugin_example.py. Docker mounts./pluginsat/plugins. The referer/headers a plugin returns are passed to ffmpeg viaMediaStream(headers=…); afile://media playlist gets ffmpeg's protocol whitelist widened. - Headless-Chromium fallback (
ENABLE_BROWSER_FALLBACK,BROWSER_FALLBACK_TIMEOUT): when yt-dlp can't extract a non-YouTube page, the page is opened in headless Chromium (Playwright), the player's media request is captured and streamed. Opt-in in the Docker image via theINSTALL_BROWSER=truebuild arg (~450 MB); no-ops without it. - New dependencies:
playwright,curl_cffi,pycryptodomex(the latter two are what extractor plugins typically need).
/vplay <link>failed with "couldn't build the thumbnail: InvalidUrlClientError" for pages yt-dlp has no extractor for — the metadata lookup returns no thumbnail andthumb()raised on it before the resolvers ever ran.thumb()now falls back to the bundled background image whenever the thumbnail is empty or can't be fetched.
- "
▶️ Play all" in/library. Any folder listing with video files directly inside it now shows a "▶️ Play all (N)" button that queues every file in that folder, in sorted order, as one playlist — plays the first immediately if idle, or appends the whole batch if something's already playing. /lplay <name>now matches folders, not just filenames. It first looks for a folder (anywhere under an allowed library category, including a category root itself) whose name contains the query and, if found, queues that whole folder the same way "Play all" does. Falls back to the existing single-file substring search when no folder matches.- Category icons in
/library, matching each category's content (🎨 cartoons, 🎬 movie, 📺 shows, 🎓 tutorials, 🎵 music), falling back to a generic 📁 for anything else.
- Proactive bot messages (auto-resume, stream-end, autoplay-next, idle-leave) could leak into "General" in a topic-locked group instead of the locked topic. These all send via
get_active_thread(chat_id), which was tracked purely per-play from the triggering command'smessage_thread_id— a separate, non-persisted mechanism from/topic lock. If that capture ever came back empty (e.g. a/vplayprocessed during a connection reconnect, where pyrogram's catch-up update can be missing topic metadata), every background message for that session fell back to no thread at all.get_active_thread()now prefersTOPIC_LOCKwhen the chat has one set — by definition of the lock, any command that could have started the session had to come from that topic anyway — and only falls back to the per-play capture for unlocked chats.
- The actual cause of 1.8.6's auto-unpause, which turned out not to be fully fixed by that release. The
.playbackattribute fix was correct but not sufficient: pytgcalls reports a genuinely paused stream'splaybackasStatus.IDLE, notStatus.PAUSED(confirmed via live logging), so it's indistinguishable from a dropped stream — no attribute-based check can tell them apart. Pause state is now tracked explicitly (driver.queues.PAUSED, set by/pause//resumeand their panel buttons) instead of inferred from ntgcalls' own state./info's Paused/Playing status now reads the same explicit flag.
/pauseauto-resumed itself (and appeared to rewind) after ~15 seconds. The stall watchdog intrack_position()is supposed to skip a deliberately paused stream by checkingcall.status == Call.Status.PAUSED— but pytgcalls'Callobject has no.statusattribute (it's.playback), so that check silently always missed. A paused stream's frozen position was treated as a silent stall, and after 15s the watchdog auto-recovered it by replaying from the last saved position — unpausing it and jumping back slightly./info's "⏸ Paused" / "▶️ Playing" status had the identical typo and always showed "Playing".
/play <search terms>and/vplay <search terms>could leave "🔍 Searching..." stuck forever and reply with an unhelpful generic error instead. The thumbnail-card step (fetch the video's thumbnail, draw the title/chat name onto it) had no error handling in either handler, so any hiccup there — most commonly a failed/slow thumbnail fetch — propagated uncaught past the status message entirely.thumb()also silently continued past a failed fetch straight intoImage.open(), which could open a stale thumbnail left over from a previous, unrelated search under the same user id rather than failing clearly. Both are now handled:thumb()raises immediately on a bad fetch (with a 15s timeout so it can't hang indefinitely), and both handlers catch it and edit the status message instead of leaving it stuck.
- The actual cause of 1.8.3's
TypeError: NoneType has no len(), which turned out not to be fixed by that release for chats with topic lock active. Root cause:other_filters(part of every command handler's filter chain) checked for/topicusing pyrogram'sfilters.command(), which mutatesmessage.commandas a side effect of every check it runs — including non-matches. Sinceother_filtersis ANDed in after the handler's owncommand([...])filter already matched and setmessage.commandcorrectly, that check clobbered it back toNonebefore the handler ever ran, 100% reproducibly, in any topic-locked chat. Replaced with a plain non-mutating text match. The 1.8.3 snapshot-before-await changes stay in place as defense in depth but weren't the real fix.
/play,/vplay,/vstream,/iptvcrashed on a YouTube link withTypeError: object of type 'NoneType' has no len(). These handlers readm.commandonly after severalawaits (permission checks, assistant-join); pyrogram's message cache can reset a cachedMessage's.commandback toNonewhen another handler's filter check runs against the same object concurrently, so by the time the code got around to reading it, it could already be gone. Now snapshotted before the firstawait. Also closed the same gap once at the source for every handler wrapped byauthorized_users_only(/skip,/volume,/record,/seek), which had a smaller version of the same race via its own admin-list lookup.
/libraryaudio/subtitle track selection silently dropped when the bot was already busy. Picking a track then hitting▶️ Play while something else was playing queued the raw, untouched file (default tracks, no subtitles) instead of your selection, with no indication it had done so. The selection is now transcoded in before queuing, so it's honored whenever it does play.
- End-of-stream messages ("streaming end", auto-DJ, auto-resume-after-drop, idle-leave) always posted to "General", ignoring topic lock — they fire from a pytgcalls lifecycle event, not a Telegram message, so there was never a thread id to read. A new per-chat "active thread" tracker records which topic the current playback session started from, and all background-task messages now post there instead.
- Global error wrapper on every command and button. Previously an unhandled exception in a handler produced total silence — nothing sent to the chat, only a line in
docker logs(that's exactly how the/topic lockcrash went unnoticed last release). Every@Client.on_messageand@Client.on_callback_queryhandler is now wrapped: message commands reply with the error, button callbacks show it as a toast alert, and both log it server-side first.
/topic lockdid nothing — threw an unhandled error on every attempt in a real forum group (chat.typeisChatType.FORUMthere, notGROUP/SUPERGROUP, and the message attribute this kurigram version exposes istopic_message, notis_topic_message). Both are now recognized correctly.- Radio/
/recordposting to "General" instead of the topic the command came from — the final now-playing card and recording-completion messages were sent without the topic's thread id. Same fix applied to/play,/vplay,/vstream, and/screenshot's status/result messages, which had the identical gap. - Radio/IPTV queuing behind other content instead of playing immediately — they're live broadcasts, not real queue items. Starting
/play,/vplay,/vstream,/library,/lplay, or/searchwhile radio/IPTV was active now interrupts it right away instead of silently queuing./iptvitself is also now always-interrupt like/radioalready was, instead of queuing behind existing content.
/topic lock|unlock|status— restrict the bot to a single forum topic in a group; every other topic there (including "General") is then silently ignored./topicitself always stays reachable so a chat can't get stuck locked. Lock/unlock require the "manage video chats" admin permission.
- Radio/IPTV stream hosts (
radiorecord.hostingradio.ru,streamguys1.com,radiofrance.fr) failing to resolve with ffmpeg's "Failed to resolve hostname" — Docker's embedded DNS resolver was mishandling CNAME-chained answers for those hosts; the container now resolves via1.1.1.1/8.8.8.8directly. /radioretries once on a transientplay()failure (DNS blips, Telegram'sINTERDC_X_CALL_ERRORon joining the voice chat).- IPTV: multi-variant HLS master playlists (e.g. Rutube) that confused ffmpeg 5.1 now resolve to a single variant before playing; added a browser User-Agent/Referer for CDNs that reject bare ffmpeg requests.
/record START END— clip a specific time range from what's playing. AcceptsHH:MM:SS,MM:SS,Nh/Nm/Ns, or plain seconds for both arguments (e.g./record 01:30:00 02:00:00records 30 minutes starting at 1 h 30 m). Works for local files and downloaded YouTube tracks; for live HTTP streams (radio, IPTV) the start offset is ignored and only the duration (END − START) is used, with a note in the status message.- Duration shorthand for
/record— e.g./record 30mor/record 1halongside the existing plain-seconds form. - Ahead-of-playback warning — when the requested start time is ahead of the current playback position (meaning yt-dlp may not have downloaded that far yet), the recording starts but the status message warns that the clip may be shorter than expected.
- IPTV (
/iptv <name>) — search 50 000+ live TV channels from the iptv-org public catalogue and stream them live; channel logo shown when playing./iptvwith no args shows links to browse channels by country/category. Works in forum groups (posts to the correct topic). - Non-YouTube URL support in
/playand/vplay— Rutube, Vimeo, and any other yt-dlp-supported site now work by extracting the stream URL and feeding it live to ffmpeg (no multi-GB download wait). YouTube continues to download first as before.
ytsearch()no longer tries to do a YouTube keyword search when given a direct URL from a non-YouTube site (was returning "no results found").callback.pywas missing thecan_manage_vcimport (undefined name, F821).- Unused
global adminsdeclaration removed fromadmins.py(F824).
1.4.1 — 2026-06-09
- Gapless Auto-DJ — the next related track is now prefetched in the background while the current one is still playing, so it starts seamlessly instead of after a short fetch pause at the end of each track.
1.4.0 — 2026-06-09
- Auto-DJ (
/autoplay) — when the queue runs out, keep playing related YouTube tracks (the song's Mix), endless-radio style. Off by default; stops on its own when the voice chat empties (idle auto-leave). Non-YouTube sources just end normally. - SponsorBlock — set
SPONSORBLOCK_REMOVE(e.g.sponsor,selfpromo,music_offtopic) to cut sponsor reads and non-music intros/outros from YouTube downloads (/play,/vplay,/search,/song,/video). Off by default.
1.3.0 — 2026-06-09
/screenshot(and a 📸 button on the panel) — grab the current video frame and send it to the chat.- Seek-to-% buttons (0 / 25 / 50 / 75 %) on the now-playing panel.
- Record toggle — the ⏺ panel button starts/stops a recording, and the recorder is reachable from the
/infopanel. - Internal version now tracks the release tags (shown in
/alive).
- Recording A/V sync — re-encodes now drop B-frames and resample audio so the picture and sound line up (audio was ~80 ms ahead).
- Empty / 0:00 video recordings — the seek is clamped inside the source, E-AC3 audio is re-encoded to AAC, HEVC/non-H.264 video is re-encoded to H.264, and the result is remuxed to faststart so Telegram plays it.
- Volume / mute changes no longer restart the video from the beginning (absolute position is tracked across re-feeds).
- Friendlier
/radioerror when no voice chat is open and the assistant can't start one. print()/ bareexceptreplaced with proper logging.
1.2.0 — 2026-06-09
- GPU (VAAPI) encoding for recordings. When
TRANSCODE_HWACCEL=vaapi, a recording that needs re-encoding (HEVC/other → H.264) is encoded on the GPU (h264_vaapi) instead of the CPU, so recording no longer stutters the live stream. Falls back to CPUlibx264when the GPU isn't configured.
1.1.0 — 2026-06-09
- Master volume via an ffmpeg gain re-feed (
/volume 0-200, 🔉/🔊) — affects the whole room, since Telegram ignores a streaming bot's own participant volume. - Record button is a toggle (⏺ Rec / Stop) on the control /
/infopanel. - Video recording that actually works end to end.
- Mute is now volume 0 (🔇 /
/vmute) instead of a media-layer mute, so muting a video no longer makes Telegram downgrade it to a blurry layer. - Friendlier
/radioerror when no voice chat is open and the assistant can't start one (explains it needs the assistant to be an admin, or a VC opened first).
- Volume/mute changes no longer restart the video from the beginning — the absolute playback position is tracked across re-feeds.
- Video recordings no longer come out 0:00 / empty:
- seek is clamped inside the source duration (a past-EOF seek produced nothing);
- audio is re-encoded to AAC (library MKVs are often E-AC3, which can't be copied into mp4);
- HEVC/non-H.264 video is re-encoded to H.264 720p so Telegram renders it;
- the result is remuxed to a faststart mp4 so the duration/preview is correct.
1.0.0 — 2026-06-09
First public release — a self-hosted Telegram bot that streams music & video into group voice chats.
- Play music & video from YouTube (search or URL) or any audio/video posted in chat.
/search— pick from YouTube results as audio or video.- Live streams (
/vstream: m3u8 / YouTube-live), with selectable quality. - Internet radio (
/radio) with the live now-playing track on the video card. - Recording (
/record) of radio/audio to a voice message, with a live tracklist. - Local media library (
/library,/lplay) with audio-track and subtitle selection. - Full playback controls — pause/resume/skip/seek/volume/loop/shuffle/queue, plus
an inline control panel and
/info. - Hardware (VAAPI) or CPU transcoding for HEVC/MKV sources.
- Self-healing — auto-reconnect on drops, resume after a restart, idle auto-leave.
- One-command Docker deploy; everything configured via environment variables.