tiennm99 913b0d0b7d feat: verify export completeness and drive runs until complete
run.sh finishes when tdl finishes, which does not mean every file arrived: a
dropped session, a stalled remote or an interrupted pass leaves gaps that
nothing reported.

verify-export.sh rebuilds the filename tdl produces for each media message in
the export JSON and checks the remote and staging for it. Messages carrying no
media are skipped rather than counted as gaps. A zero-byte file counts as
missing because rclone overwrites a size-mismatched destination, so a retry
repairs it; files under 1 KiB are reported but not retried, since real media is
sometimes that small and retrying them would never terminate.

export-until-complete.sh loops that check with run.sh, narrowing the export to
the outstanding ids each pass. It stops when the verifier reports complete, when
a pass fetches nothing new, when the remote runs low on space, or on Ctrl-C.
tdl's progress bar is shown on a terminal and suppressed when output is
redirected, so logs stay readable.

Exports, staging and logs are now ignored: they hold real message ids, file
names and text from an account.
2026-09-03 22:42:32 +07:00
2026-08-11 14:07:26 +07:00

telegram-exporter

Export a Telegram chat's media to any rclone remote — S3, Google Drive, Dropbox, Backblaze B2, SFTP, WebDAV, or anything else rclone supports — using far less local disk than the chat's total size.

run.sh runs tdl and rclone as a rolling pipeline: tdl downloads into a small staging directory while rclone concurrently moves finished files to the remote and deletes the local copies. Local disk only ever holds the files in flight plus one sync interval of throughput, so a multi-terabyte chat exports fine on a small disk. Telegram caps a single file at 2 GB (4 GB from premium uploaders), so a few dozen GB of staging covers the worst case regardless of chat size.

This is needed because tdl can only write to a local directory — it has no rclone integration and no remote destination of any kind (tdl dl -d takes a filesystem path; tdl --storage is its session database, not an output target). tdl downloads over MTProto with a user account, so Bot API limits do not apply: full history is readable and there is no 20 MB download cap.

Requirements

Setup

Both steps are one-time.

# 1) log in to Telegram with your user account (phone + code + 2FA)
tdl login

# 2) configure the destination. The interactive wizard covers every backend:
rclone config

# ...or create one non-interactively, e.g.
rclone config create gdrive drive
rclone config create b2 b2 account=KEY_ID key=APP_KEY
rclone config create dav webdav url=https://dav.example.com/remote.php/dav/files/you \
  vendor=other user=YOU pass=SECRET

rclone listremotes          # confirm the name you will pass to -r

Any rclone remote form works, including on-the-fly connection strings (:webdav,url=https://...:/path).

Usage

./run.sh -r gdrive:telegram/media -c @mygroup

That is the whole flow. It exports the chat's message metadata to export.json, then downloads and uploads concurrently until finished. Progress and warnings go to stderr; press Ctrl-C at any point and it stops cleanly.

Options

Flag Meaning
-r REMOTE:PATH Required. rclone destination, e.g. gdrive:telegram/media, s3:bucket/tg, dav:tg-export
-c CHAT Chat to export when the JSON does not exist yet — id, username, or link (see below)
-f FILE Export JSON to download from (default export-<chat>.json with -c, else export.json)
-d DIR Staging directory (default ./staging)
-i SECONDS Seconds between rclone sweeps (default 60)
-a AGE rclone --min-age, a second guard against moving files still being written (default 2m)
-h Help

Identifying the chat

-c accepts every form tdl understands, plus one it doesn't:

Form Example
Numeric id, as printed by tdl chat ls -c 1697797156
Username, with or without @ -c @mygroup / -c mygroup
Public link -c https://t.me/mygroup / -c t.me/mygroup
Deep link -c 'tg://resolve?domain=mygroup'
Bot API id (converted for you) -c -1001697797156 → 1697797156

tdl resolves a numeric argument as an MTProto id and anything else through gotd's resolver. MTProto has no -100 prefix, so a Bot API id would otherwise fail to resolve; the script strips it and logs the conversion.

A message link is rejected — -c names a chat, not a message:

$ ./run.sh -r gdrive:tg -c https://t.me/mygroup/123
error: -c takes a chat, not a message link — pass the chat's username or id

Run tdl chat ls to see ids and usernames side by side.

Each chat gets its own export file by default (export-mygroup.json, export-1697797156.json), so exporting a second chat from the same directory never reuses the first one's JSON. When the file already exists it is reused and the script says so — delete it to re-export.

Anything after -- is passed straight to tdl dl:

./run.sh -r gdrive:telegram/media -- -t 4 -l 1      # calmer parallelism, fewer flood waits
./run.sh -r gdrive:telegram/media -- -i mp4,mkv     # only these file extensions
./run.sh -r gdrive:telegram/media -- -e jpg,png     # skip these file extensions

tdl defaults to -t 8 -l 4, which is aggressive; lower it if you hit flood waits on a large export.

Tuning the upload

rclone reads every one of its flags from an environment variable, so the upload side is tunable without touching the script:

RCLONE_TRANSFERS=8 RCLONE_BWLIMIT=20M ./run.sh -r s3:bucket/tg -c @mygroup

Exporting a subset

Generate the JSON yourself when you want a narrower export, then point -f at it:

tdl chat export -c @mygroup -T id -i 1000,5000 --all --with-content -o part.json
./run.sh -r gdrive:telegram/media -f part.json

tdl chat export takes -T time|id|last with -i as the range, and -f as an expression filter over message fields (-f - lists the available fields).

Resuming

Re-run the same command. Both legs resume independently and nothing is downloaded or uploaded twice.

Keep the same export.json between runs: --skip-same compares against the staging directory, which is empty once files have moved to the remote, so cross-run deduplication rests on tdl's own --continue tracking. If you must start from a fresh export, narrow it to the missing message-id range (-T id -i <last>,<max>) rather than re-downloading everything.

Verifying an export

run.sh finishes when tdl finishes, which is not the same as every file having arrived: a dropped session, a stalled remote, or an interrupted pass all leave gaps. verify-export.sh settles it by rebuilding the filename tdl produces for each media message in the export JSON and checking the remote for it.

./verify-export.sh -f export-mygroup.json -r remote:telegram/media
messages in export : 18193
  text-only (skip) : 38
  media expected   : 12000
present and intact : 12000
  absent           : 0
  zero-byte        : 0

COMPLETE: every media message is present and non-empty.

Messages with no media are skipped; they carry an empty file and were never download targets. A zero-byte file counts as missing, because rclone overwrites a size-mismatched destination and a retry repairs it. Files under 1 KiB are reported but not retried, since some real media is genuinely that small. Exit status is 0 when complete and 1 otherwise, with the outstanding message ids written to missing-ids.txt.

Running until complete

export-until-complete.sh drives run.sh in a loop: verify what is already there, narrow the export to the ids still missing, run the pipeline on that subset, and repeat.

./export-until-complete.sh -r remote:telegram/media -c @mygroup
Flag Meaning
-r REMOTE:PATH Required. rclone destination
-c CHAT Chat to export metadata for on the first pass
-f FILE Export JSON (default export-<chat>.json)
-d DIR Staging directory (default ./staging)
-i SECONDS rclone sweep interval (default 300)
-p N Maximum passes (default 20)
-q GIB Stop if remote free space falls below this (default 5)

It stops when the verifier reports complete (exit 0), when a pass fetches nothing new (exit 1 — the remaining media is no longer available from Telegram), when the remote runs low on space (exit 3), or on Ctrl-C (exit 130, after the current pass shuts down cleanly).

tdl's progress bar is shown when stdout is a terminal and suppressed when output is redirected, so a log file stays readable without a flag.

What it guards against

  • Partial uploads. tdl writes <name>.tmp and renames on completion, so every sweep excludes *.tmp. Age alone is not a completion signal: a download stalled by a flood wait stops touching its .tmp, which would then be uploaded half-written and lose its resume point.
  • Directories vanishing under tdl. --delete-empty-src-dirs runs only in the final sweep, and the staging directory is recreated after every sweep. Removing a directory under a running tdl makes it fail to create its next file.
  • Orphaned downloads. tdl is stopped on exit, Ctrl-C, or SIGTERM, so no download keeps running after the script is gone.
  • A failed run looking finished. The unrestricted final sweep happens only after tdl exits 0. An interrupted or crashed run gets the age-guarded sweep and keeps staging for the next attempt.
  • A dead remote filling the disk. Five consecutive rclone failures abort the run instead of letting staging grow unbounded.
  • Typos and bad credentials. Before downloading anything, the remote must be present in rclone listremotes (skipped for connection strings) and the destination must be creatable, which proves both reachability and auth.

Exit codes: 0 success, 2 usage error, 3 rclone failure, 130/143 interrupted, anything else is tdl's own exit code.

Limits

  • Streaming with no staging at all (piping download chunks straight to the remote) is not possible with tdl and would require custom code.
  • The script is bash; the two tools it drives are cross-platform, but Windows needs WSL or Git Bash.
S
Description
No description provided
Readme Apache-2.0
624 KiB
0 Stars 1 Watchers 0 Forks
Languages
Go 100%