Turn your Telegram into a private Netflix for your TV. TGTV is a free, open-source Android TV app that you sideload onto your Android TV to browse, stream and download videos stored in your Telegram account — Saved Messages, private library groups, channels. No ads. No upload limits. Your Telegram account is the server.
- Runs on: Xiaomi Mi Box, NVIDIA Shield, Fire TV (sideload), Chromecast with Google TV, and any Android TV running Android 8.0+
- Works with: Telegram groups, channels, Saved Messages — videos travel as documents and still play
- Stack: Kotlin · TDLib · Leanback · Media3 / ExoPlayer
Start here: Install on your TV → Create a bot token → Log in. Two 5-minute steps.
Also explore Mobile version : https://github.com/aravindbuilds/TGTV
- Three launcher themes - Settings > App icon switches the launcher between CRT (classic blue, default), Noir (amber) and Cyberpunk (neon) - no reinstall needed.
- Series awareness - episode files (SxxEyy / NxNN / Season-Ep) resolve to the show on TMDB; the details line shows an Sxx Eyy badge and subtitles are pinned to the exact episode.
- Continue Watching row - progress cards with real art, a 'Show - Sxx Eyy' title and resume on OK.
- Sort & view - the dialog now lists every option with the active one checked, including the detailed file-list view.
- Settings reordered by usability - Search, Refresh, Sort & view, Add chats and Sync first; one-time setup and update toggles last.
- Watch progress syncs across your devices — TGTV quietly writes a position bookmark into your Saved Messages while you watch. Open the same title later on another device running TGTV and it offers to resume exactly where you left off. Nothing leaves your own Telegram account.
- Cloud backup & restore — Settings → Backup / Restore packs your library
layout (pinned chats, watch progress, preferences) into a private Saved Messages
file (
#TGTVBackup); restore it on any TV or after a factory reset in one click. - Search everything — the search icon now searches all of Telegram (every chat this session can see), not just the rows pinned to your library.
- Playback hardening — stale file references self-heal (re-resolved from the message instead of the expiring remote id), streaming retries transient network failures instead of dying mid-movie, playback errors offer a working retry, and the player falls back to any other available decoder when the preferred one fails.
- In-app updates — Settings → Check for updates downloads the newest release from GitHub and launches the installer; a Beta updates toggle opts into pre-releases.
- User login is private by default — QR (phone) sessions now show only Saved Messages instead of every chat on the account. Your other chats, groups and personal media never reach the shared TV screen.
- Add chats (Settings) — want more than Saved Messages? Settings → Add chats shows a checkbox list of every chat this session knows; pin any chat or group to the library and it appears as its own row. Unpin to hide it again. Bot sessions can pin too (it also persists their chat list across restarts).
- Subtitles just work — no API key needed — the OpenSubtitles.com key is now compiled into the release APK (like the Telegram credentials), so automatic subtitle fallback needs zero setup. All "please add your API key" prompts are gone.
- "Add subtitles" in the player — Settings → Add subtitles (OpenSubtitles) while watching searches OpenSubtitles on demand and side-loads the SRT without losing your position — handy when the file's embedded subs are wrong or missing a language.
- No API keys needed on the TV — the shared Telegram app credentials are compiled into the release APK, so setup is now just the @BotFather token (or skip that too and log in with your Telegram account by QR).
- Resume playback — where you left off is saved locally; the details screen offers Continue / Play Again, and titles watched to the end are marked as seen.
- In-player settings panel (press MENU or INFO while playing): switch subtitle and audio tracks, playback speed (0.25x–2x) and a resolution cap (Auto/4K/1080p/…) without leaving the video. Hold MENU for a quick subtitles on/off.
- Your library survives restarts — chats and videos are indexed in a local SQLite database, so even bot mode (which Telegram doesn't allow to read history) reopens full library instantly after a cold start.
- Remove from TGTV — long-press → Remove hides a title from the TV only; the Telegram post stays untouched, hidden titles stay hidden across scans.
- Zero-credential login on TV — with baked credentials the login screen goes straight to the QR "Link device" flow, no forms.
- Subtitles now render at the bottom of the TV screen (not the letterboxed video), with a Small/Medium/Large size setting; audio pauses no longer risk the box sleeping during playback (wake lock held); details screen shows cast & crew (via TMDB credits) and a quality badge (1080p, 4K, WEB-DL…) on the hero.
Library & browsing
- Netflix-style TV UI — poster rows, focus navigation with the remote, black + red theme
- One row per chat, newest videos first, real video-frame thumbnails on every card
- Every video gets a details screen: full-bleed TMDB backdrop, ★ rating, runtime, status, overview, genres, cast & director, and Play / Add to Watchlist / Download buttons
- TMDB metadata built in — files are identified by parsing their release-style name (quality tags, audio languages, group junk), with spelling-tolerant retries and a web-assisted fallback (Wikipedia / DuckDuckGo → canonical title → TMDB)
- Version picker — multiple files of the same title (1080p vs 720p, language cuts) are grouped on the details screen with size/quality/audio labels
- My List — watchlist bookmarked locally on the device
Playback
- Streaming via TDLib range downloads read straight from the partial local file (no Bot API 20 MB limit, no external file hosts)
- Seek anywhere — data is fetched from the seek position, not the file start
- YouTube-style time-based buffering: starts with ~2 s buffered, look-ahead grows to 5 minutes of video
- Resume / Continue watching — position saved locally, cleared after finishing
- In-app updates — Settings → Check for updates downloads the newest release from GitHub and launches the installer; a Beta updates toggle opts into pre-releases (like the phone app)
- Embedded subtitles plus OpenSubtitles.com fallback (key built into release builds) matched by TMDB ID and side-loaded without losing position
- Player settings: subtitles/audio track/speed/quality cap plus on-demand Add subtitles (OpenSubtitles), reachable from MENU/INFO and the gear button
Storage & downloads
- Full download to
Movies/TgTVvia MediaStore on API ≥ 29, private app dir on older devices; foreground service with progress notifications - Cache hygiene — a video's cached bytes are deleted 20 s after you exit it, and the whole TDLib cache is capped at 2 GB (trimmed LRU to ~1.5 GB)
- Storage-aware playback — big files warn at open if free space is insufficient and pause before the device runs out of disk
Other "Telegram to TV" apps forward your file to a third-party hosting bot and stream the resulting public URL. TGTV streams in-process: TDLib downloads the byte ranges the player needs straight onto the device and ExoPlayer reads them from disk — your files never leave the Telegram network on the way to your screen. No Bot API 20 MB cap, no external player dependency, no public file links. Technical details: How streaming works.
Grab the APK from Releases:
TGTV-v1.4-v7a.apk (latest) for any Android TV (~24 MB; works on every device
including arm64/x86 ones). A -universal variant (~96 MB, all 4 ABIs) exists
for emulators.
Android TVs have no app store for this, so you sideload it. Pick whichever method suits you — all end with the same APK install dialog on the TV.
Step 0 — allow app installs on the TV (needed by every method)
- On the TV open Settings → Device Preferences (or Apps) → Security & Restrictions
- Under Install unknown apps, pick the app you will install from (file manager, Send files to TV, or ADB shell) and toggle Allow from this source
- (ADB method only) also enable Settings → Device Preferences → About → Network → tap Build / Android TV OS build 7 times to unlock Developer options → Network debugging (ADB)
Method A — USB drive (no computer, no internet tricks)
- Copy the APK you picked to a USB stick (FAT32/exFAT both work)
- Plug the stick into the TV's USB port
- Open any file manager on the TV (most devices have one; if not, install FX File Explorer or Solid Explorer from Play Store first)
- Browse to the stick → select the APK → press OK → Install → Open
Method B — over Wi-Fi (no USB, no ADB)
- Install Send Files to TV on both the TV (from Android TV Play Store) and your phone
- Open it on the TV: Receive → note the TV's IP
- On the phone: Send → select the APK → pick the TV's IP → wait for the transfer
- On the TV, the received APK opens inside the app → Install
(Any LAN transfer app works the same way: LocalSend, TV File Transfer, …)
Method C — ADB over the network (last resort)
From a PC on the same Wi-Fi, in the folder containing the APK:
adb connect <tv-ip>:5555 # some devices use :5037, Fire TV shows the port in Settings
adb install -r TGTV-v1.4-v7a.apk # latest release APK
adb shell am start -n com.tgtv.videotv/.ui.MainActivityIf adb connect fails, enable Developer options → Network debugging (Step 0),
or connect once with USB-ADB (Fire TV: Settings → My Fire TV → Developer options).
Troubleshooting
| Symptom | Fix |
|---|---|
| "App not installed" mid-install | Internal storage full — delete unused apps / empty app caches, then retry |
| Install button does nothing after "unknown apps" toggle | Re-do Step 0 and toggle the actual app performing the install |
| Launch opens nothing | Reboot the TV once after install, then reopen |
The release APK has Telegram's app credentials (api_id/api_hash) built in, so this is the only single stop:
- On your phone open Telegram → search @BotFather → send
/newbot - Copy the bot token it gives you (format
123456:AAH...)
- In Telegram, create a private group or channel (e.g. "My TV Library") and add the bot as a member / admin. This group is your TV library — only what you deliberately post or forward there will appear on the TV.
- On the TV, open TGTV → choose Bot token → paste the token → Save and connect. (Prefer no bot at all? Choose QR login on the TV's login screen and scan the code with your phone — Settings & Devices → Link Desktop Device. That logs in as your own account and shows only your Saved Messages — send a video to yourself there and it appears on the TV.)
- Post or forward a video into the library group → it should appear as a row on the TV within seconds.
Upgrading from v1.0: the API-credentials form is gone on the TV. If the app opens with your old session already active, nothing to do; if it asks for credentials again, just use the same bot token (or switch to QR login) once.
- Post or forward movies into your library group from your phone — the TV library updates live.
- Watch part of a movie? The next time you open it, choose Continue to resume.
- Long-press a card for Play / Download / Remove from TGTV.
- Press MENU/INFO while playing for subtitles, audio track, speed and quality cap.
| Bot mode (recommended) | QR user login | |
|---|---|---|
| Log in as | your @BotFather bot | your own Telegram account |
| Library source | a private group/channel you curate | Saved Messages, plus any chats you pin in Settings → Add chats |
| History | live only (Telegram forbids bots from reading history) | full history of Saved Messages and pinned chats |
| Curation boundary | total — nothing else from your account ever appears | total — nothing else from your account ever appears |
| Resilience | library survives restarts instantly via the local index (v1.1) | same |
Recommendation: either mode keeps the rest of your account private; pick by workflow. Bot mode is the "TV library appliance" — a private group you own, the bot as librarian, manage it from any device. QR login skips the bot entirely: send or forward a video to Saved Messages on your phone and it appears on the TV, with its full history intact.
In v1.1 the "bots can't read history" limitation mostly disappears in practice: once a video has been captured live, it is stored in the local library index and shows again after every restart.
- Bot live-only history — Telegram's server refuses history requests for bot accounts. The local index keeps everything the bot has seen in this session's DB forever, but the first-ever content must arrive once while the app is connected.
- QR login is Saved Messages only (by default) — a deliberate privacy boundary: user sessions could technically read everything, so TGTV pins them to Saved Messages to keep the rest of your account off the shared screen. Use Settings → Add chats to pin specific chats or groups you do want.
- Exotic codecs — playback relies on the TV's hardware codecs; AVI/DTS media may fail on some Android TVs.
- Storage during long movies — streamed bytes are cached by TDLib as they are
watched (~the whole file by the end of a full playthrough). The cache is purged
~20 s after you exit and capped at 2 GB when idle, but during playback local
usage grows with what you've watched. TGTV warns at open and pauses before running
out, but the practical tip remains: keep what you play under the TV's free
/dataspace (file size is visible on every card and in the version picker; on a Mi Box 4 with ~2 GB free, ≤ ~1.5 GB streams through cleanly). - One video downloads at a time (foreground service; parallel queue is a TODO).
- Thumbnails are optional — no thumb means a blank card, but the video still plays.
| App version | v1.4 (stable) — versionCode 11 |
| Android | 8.0 (API 26) and newer |
| Tested on | Xiaomi Mi Box 4 (Android 9, armeabi-v7a) |
| Variants | v7a (armeabi-v7a only, ~24 MB — recommended for all devices) and universal (all 4 ABIs, ~96 MB — emulators / exotic arm64 devices) |
| Target SDK | 35 |
| TDLib | io.github.tdlib-android:core 0.1.1 (TDLib 1.8) |
The project contains no API secrets. Release builds read api_id / api_hash at
build time from an untracked properties file and bake them (XOR-obfuscated) into
a generated Kotlin object; without one, the app builds fine but shows the good-old
manual-credentials login form. File searched in order:
<project>/secrets.properties<parent>/tgtv-secrets.properties
Both with api_id=<numeric> and api_hash=<hex string from my.telegram.org/apps>.
Android Studio: open the root, let Gradle sync (pulls the ~40 MB
io.github.tdlib-android:core:0.1.1 AAR from Maven Central), run on a TV or emulator.
Terminal (adb connected to the TV):
# Build both release APK outputs
.\gradlew assembleV7aRelease assembleUniversalRelease # Gradle 8.11.1+ locally
# APK output (not committed)
app/build/outputs/apk/v7a/app-v7a-release.apk
app/build/outputs/apk/universal/app-universal-release.apkReleases are signed with the debug keystore, so users can install over older versions without uninstalling.
No wrapper jar is committed: command-line gradlew needs a local Gradle
installation; Android Studio's bundled Gradle handles sync/run.
ExoPlayer (progressive) -> TdVideoStreamDataSource.read()
- 1 KiB-aligned window around the read cursor
-> TdLib.downloadRange(fileId, offset, limit) [mutex-serialised,
synchronous TdApi.DownloadFile — TDLib physically writes the range
into its partial local file]
<- read that range straight from the partial file (RandomAccessFile)
- Why not
ReadFilePart? TDLib'sFileManager::read_file_partrejects any range beyond the contiguous downloaded prefix from byte 0 ("There is not enough downloaded bytes in the file to read"), which breaks every stream touching a far range (MKV Cues at EOF, MP4 tail atoms). RangeDownloadFilewrites are not subject to that gate, so the DataSource downloads the window synchronously and reads bytes directly off disk. - All range downloads share one mutex: TDLib keeps at most one pending
DownloadFileper file — a new request cancels the in-flight one. - Each DataSource gets a fresh instance: ExoPlayer opens several DataSources
concurrently (MP4 reads the
moovtrailer at EOF while the main loader reads the start) and they must not share mutable state. - After each window a single 4 MiB fire-and-forget prefetch is queued.
- Per-video purge: on player exit the file's pending downloads are cancelled and its cache entry deleted after a 20 s grace (so instant replays keep bytes).
- Global 2 GB cap:
TdLib.enforceCacheCap()measurestdlib_files/and, when over 2 GB, callsOptimizeStorage(1.5 GB)so TDLib drops least-recently-used files first. Runs after login and after every purge; skipped while streaming. - Post-v1.0 re-resolve: stored library rows keep the file's stable remote id; on restart the stale session-local file id is re-resolved to a live one instead of breaking playback.
Long-press a card → DownloadService starts (foreground, dataSync):
DownloadFile(fileId, priority=24, offset=0, limit=0)— whole file, plusUpdateFilelistener for % progress.- On
isDownloadingCompleted→ copylocal.pathintoMovies/TgTV/<fileName>via MediaStore (IS_PENDINGround-trip). RemoveFileFromDownloads(fileId, deleteFromCache=false)— drops the manager record, keeps the cached bytes.
TelegramVideoTV/
├── README.md <-- this file
├── settings.gradle.kts Module graph + Maven Central / Google repos
├── build.gradle.kts Root: AGP 8.9.3 + Kotlin 1.9.24 plugins
├── gradle.properties JVM args, AndroidX flags
├── secrets.properties* (untracked) build-time TDLib api_id/api_hash
├── docs/screens/ README screenshots
└── app/
├── build.gradle.kts v7a/universal ABI flavors; release builds read
│ the BakedApi secrets file above; deps:
│ tdlib-android:core:0.1.1, leanback, media3,
│ zxing (QR), NanoHTTPd (phone pairing), coroutines
├── proguard-rules.pro Keep org.drinkless.tdlib classes
└── src/main/
├── AndroidManifest.xml Permissions (INTERNET, WAKE_LOCK,
│ FOREGROUND_SERVICE+FOREGROUND_SERVICE_DATA_SYNC,
│ POST_NOTIFICATIONS), LEANBACK_LAUNCHER intent
│ filter, download service, landscape /
│ keep-screen-on player activity
├── assets/setup.html The "pair via phone" credential page served
│ over the LAN by the embedded HTTP server
├── res/
│ ├── drawable/ Launcher/banner icons, pill/chip backgrounds
│ ├── layout/ activity_login / activity_details /
│ │ activity_player and related views
│ └── values/ strings.xml colors.xml themes.xml
└── java/com/tgtv/videotv/
├── App.kt Application: starts TDLib at boot when
│ credentials are already saved
├── model/
│ ├── VideoItem.kt Playable-video data class, incl. the stable
│ │ remote file id + kind used to re-resolve
│ │ session-stale file ids after a restart
│ └── VideoMapper.kt TdApi.Message -> VideoItem (bottom half of
│ the streaming pipeline): video / video-note /
│ GIF / video-document w/ extension whitelist
├── store/
│ └── VideoLibrary.kt SQLite index of the library: video refs
│ (chat+message, metadata, remote id), hidden
│ entries and resume-progress tables — instant
│ restart and bot-mode persistence
├── td/
│ ├── Credentials.kt SharedPreferences for api id/hash/token,
│ │ subtitle settings; falls back to baked values
│ ├── TdError.kt TDLib error wrapper (code + message)
│ └── TdLib.kt Singleton TDLib client: bot + QR user login
│ state machines, thread-safe suspend send(),
│ cache cap, downloadRange()
├── ui/
│ ├── MainActivity.kt Activity host; adds the browse fragment
│ ├── MainBrowseFragment.kt Leanback BrowseSupportFragment: chat list
│ │ (LoadChats + local index), rows, actions
│ │ (refresh / pair / sign out / TMDB /
│ │ subtitles), card click & long-press
│ ├── VideoCardPresenter.kt ImageCardView renderer; lazy thumbnail
│ │ decode from TDLib-downloaded thumbs
│ ├── VideoRow.kt One row = one chat + its VideoItems
│ ├── LoginActivity.kt Bot-token / QR login (baked creds: straight
│ │ to QR) + LAN "pair via phone" option
│ ├── DetailsActivity.kt Details screen: TMDB hero, Continue / Play
│ │ Again / Watchlist / Download / Remove
│ ├── PlayerActivity.kt Full-screen ExoPlayer: resume, in-player
│ │ settings (subs/audio/speed/quality cap),
│ │ screen-anchored subtitles, wake lock
│ └── TdVideoStreamDataSource.kt Media3 DataSource mapping reads to
│ TDLib range downloads (1 MiB), the core of
│ Telegram streaming
└── svc/
└── DownloadService.kt Foreground service: full file download +
MediaStore Movies/TgTV copy + progress
notification cleanup
- Paging / infinite scroll (user mode only — bots can't search history):
searchChatMessageswithnextFromMessageId(already returned inFoundChatMessages). - Storage-curve UI: expose
TdLib.enforceCacheCap()and the measured cache size as a Settings action ("Clear cache"). - Multi-download queue: hold a
ConcurrentLinkedQueue<VideoItem>inDownloadServiceinstead of the single-job guard.