Metadata-Version: 2.5
Name: ttdl-cli
Version: 0.1.0
Summary: Archive a whole TikTok account: videos, photo carousels, metadata, thumbnails
Project-URL: Homepage, https://ttdl.orlovsky.dev
Project-URL: Repository, https://github.com/sadorlovsky/ttdl
Author: Zach Orlovsky
License-Expression: MIT
License-File: LICENSE
Keywords: archive,downloader,tiktok,yt-dlp
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: System :: Archiving
Requires-Python: >=3.8
Description-Content-Type: text/markdown

<div align="center">

# ttdl

**Archive a whole TikTok account** — videos, photo carousels, metadata, thumbnails.<br>
A thin wrapper around `yt-dlp`, so there is no parser of our own to maintain.

[![tests](https://github.com/sadorlovsky/ttdl/actions/workflows/tests.yml/badge.svg)](https://github.com/sadorlovsky/ttdl/actions/workflows/tests.yml)
[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

**Documentation: [ttdl.orlovsky.dev](https://ttdl.orlovsky.dev)**

</div>

Runs are resumable and incremental, and the script handles the parts TikTok makes hard: listings
that truncate at random depth, a third of requests failing for no reason, and photo posts that
silently save as audio only.

```bash
./ttdl.py @username
```

## Install

```bash
brew install sadorlovsky/tap/ttdl
```

yt-dlp and ffmpeg come with it. Without Homebrew, install those two yourself and then ttdl — one
file, no dependencies beyond the standard library:

```bash
uv tool install ttdl-cli   # or pipx install ttdl-cli
```

The package is `ttdl-cli` because `ttdl` on PyPI is somebody else's; the command is `ttdl`. A
container carrying the whole toolchain is the third route:

```bash
docker run --rm -v "$PWD/downloads:/archive" --user "$(id -u):$(id -g)" \
  ghcr.io/sadorlovsky/ttdl @username
```

Or clone it and run `./ttdl.py` in place — see [Install](https://ttdl.orlovsky.dev/start/install/).

**yt-dlp has to be recent — `2026.03.13` or newer.** TikTok only serves pages after a JS challenge,
and yt-dlp solves it itself, in Python; no JavaScript runtime is involved. `./ttdl.py doctor` checks
that and everything else a run depends on.

## Usage

Fourteen commands. Omit the command and `get` is implied, since that's the common case:

| Command | What it does |
|---|---|
| `get` | download what's missing (default) |
| `meta` | backfill metadata and thumbnails for files that lack them |
| `adopt` | take over files downloaded by another tool |
| `loudness` | measure each post's loudness into `loudness.json` |
| `audio` | restore sound in videos TikTok stored without it |
| `check` | reconcile the archive against what's on disk |
| `push` | upload the archive to storage |
| `list` | print the post list, download nothing (`--all` walks the profile, `--new` reads its top) |
| `status` | what an archive holds and still owes, read from disk and changing none of it |
| `report` | write `report.html`: what's archived, what isn't, and why (reads files only) |
| `watch` | serve a live view of the archives on localhost while a run works |
| `doctor` | check the tools and paths a run depends on, before a long run finds out |
| `path` | print where an archive lives, and nothing else — for `cd "$(…)"` |
| `completion` | print a zsh completion, generated from the argument parser so it can't drift |

```bash
./ttdl.py @username                        # download what's missing
./ttdl.py @username --new                  # and pick up newly published posts
./ttdl.py meta @username                   # backfill metadata
./ttdl.py push @username r2:bucket/user    # upload to storage
./ttdl.py get https://www.tiktok.com/@username/video/7467909701850696968   # one post
./ttdl.py --from likes.txt                 # a list of posts, from anywhere
./ttdl.py @username --dry-run              # what a run would fetch, without fetching it
./ttdl.py status                           # one line per archive: what's here, what's owed
./ttdl.py report --open                    # an HTML report per archive, opened in the browser
./ttdl.py doctor                           # check the toolchain, no network, no target
```

Files land in `downloads/<username>/` as `20260814_7673909736131038495_Caption.mp4`, with
`.info.json` and a `.jpg` thumbnail alongside; run state goes in `.ttdl/` beside them. Re-running
only fetches what is missing, so the script is safe to put on a cron job.

Every command takes `--out DIR` (default: `$TTDL_OUT` if set, else `./downloads`) and
`--cookies BROWSER|FILE` — TikTok is friendlier to logged-in requests, and Chrome's jar can't be
read at all. `get` takes twenty more flags on top: see
[Commands and flags](https://ttdl.orlovsky.dev/reference/cli/).

## Recipes

**Archive an account for the first time.** The profile walk takes a while and the first pass never
gets everything, so expect to run it more than once:

```bash
./ttdl.py @username           # a first run walks the whole profile by itself — no --all needed
./ttdl.py @username --all     # listings truncate at random depth; repeat until the count stops growing
```

→ [Your first archive](https://ttdl.orlovsky.dev/start/first-archive/)

**Keep an archive current.** Safe to put on a cron job — `--new` reads only the top of the profile,
and downloading straight through to storage leaves nothing on the machine:

```cron
17 4 * * * cd /srv/ttdl && ./ttdl.py @account --new --remote r2:bucket/account --move --out /srv/tmp/ttdl >> /srv/logs/ttdl.log 2>&1
```

State is pulled back from storage at the start of a run, so the local buffer can be wiped between
runs and the archive still resumes where it left off.
→ [Keeping an archive current](https://ttdl.orlovsky.dev/guides/keeping-current/) ·
[Offloading to storage](https://ttdl.orlovsky.dev/guides/remote-storage/)

**Likes, favorites, or any list of posts.** yt-dlp has no extractor for those sections — they're
private — so hand the list over instead. Ids and post URLs are both accepted, one per line:

```bash
./ttdl.py --from likes.txt    # -> downloads/liked/
```

→ [Likes, favorites and lists](https://ttdl.orlovsky.dev/guides/lists/)

**When TikTok is rate-limiting hard** — just run it again. Each pass clears roughly 60% of whatever
is left, which converges fast: 1121 → 453 → 179 → 79 → 26.

```bash
./ttdl.py @username --rounds 6 --cookies firefox
```

→ [The rate limit, measured](https://ttdl.orlovsky.dev/explanation/rate-limit/)

**See what didn't download and why.** A run that ends with holes exits `5` and writes `missing.txt`
with a reason per post, grouped by what would actually fix them — a rate limit clears on a retry, a
login wall never does. `status` prints the same verdict for one archive or all of them, `report`
writes a self-contained page, and `watch` serves a live view of a run in progress.
→ [Seeing what is missing](https://ttdl.orlovsky.dev/guides/reports/) ·
[Exit codes](https://ttdl.orlovsky.dev/reference/exit-codes/)

## How it works

Everything reduces to one operation: **post list minus archive = what's missing, fetch that.** It
repeats with growing delays until nothing is left or progress stalls. That's also where the
resilience comes from — the first pass and a retry are the same operation, differing only in pacing.
Interrupt a run and the next one simply sees a longer to-do list.

`archive.txt` is maintained by yt-dlp itself (`--download-archive`), and that's where idempotence
comes from. It and the rest of the run state live under the archive's `.ttdl/`, so the archive
directory itself holds only media and per-post sidecars.
→ [What lands on disk](https://ttdl.orlovsky.dev/reference/archive-layout/) ·
[The state files](https://ttdl.orlovsky.dev/reference/state-files/)

## Gotchas

These cost real time to discover, so they're worth knowing before you hit them. Each is written up
in full under [Notable decisions](https://ttdl.orlovsky.dev/explanation/decisions/) and
[Known limits](https://ttdl.orlovsky.dev/explanation/known-limits/).

- **Rate limiting is the main obstacle.** On a large account roughly a third of requests fail with
  `Unexpected response from webpage request`. Nothing is broken — the same videos download fine
  individually, and that's what the repeated passes are for.
- **Profile listings truncate at random depth.** Three consecutive walks of one profile returned
  270, 1125 and 4606 posts, each set fully contained in the next
  ([yt-dlp#15348](https://github.com/yt-dlp/yt-dlp/issues/15348)). So the post list is merged, never
  replaced, and a post missing from a listing proves nothing about deletion.
- **`HTTP Error 404` on a post that is not gone.** TikTok can advertise a render its CDN does not
  hold; `get` notices and retries those posts as h264 by itself, at 540p.
- **Photo carousels are invisible to yt-dlp**
  ([yt-dlp#10034](https://github.com/yt-dlp/yt-dlp/issues/10034)) — it sees a single `audio` format
  and saves the soundtrack. This script pulls the images out of the page JSON.
- **TikTok lies about audio.** Every render is advertised with `acodec: aac` and some hold no audio
  track; `get` takes the sound from the h264 render and muxes it onto the file already on disk.
- **The first minutes look like a hang.** The profile walk runs before any downloading — on a few
  thousand posts that's ~190 pages. The list is cached, so you pay for it once.
- **`--impersonate` makes things worse.** yt-dlp warns that `curl_cffi` is missing; installing it
  and passing `--impersonate chrome` breaks the TikTok extractor outright. Ignore the warning.
- **Deleting files doesn't trigger a re-download.** A post counts as fetched based on `archive.txt`,
  not on the file being there. `status` shows the gap without touching anything; `check` reconciles
  the two.

## Documentation

- **Start** — [What this is](https://ttdl.orlovsky.dev/start/what-this-is/) ·
  [Install](https://ttdl.orlovsky.dev/start/install/) ·
  [Your first archive](https://ttdl.orlovsky.dev/start/first-archive/)
- **Guides** — [Keeping an archive current](https://ttdl.orlovsky.dev/guides/keeping-current/) ·
  [Likes, favorites and lists](https://ttdl.orlovsky.dev/guides/lists/) ·
  [Adopting an existing folder](https://ttdl.orlovsky.dev/guides/adopting/) ·
  [Offloading to storage](https://ttdl.orlovsky.dev/guides/remote-storage/) ·
  [Seeing what is missing](https://ttdl.orlovsky.dev/guides/reports/) ·
  [Evening out the volume](https://ttdl.orlovsky.dev/guides/loudness/) ·
  [Running through a proxy](https://ttdl.orlovsky.dev/guides/proxy/)
- **Reference** — [Commands and flags](https://ttdl.orlovsky.dev/reference/cli/) ·
  [What lands on disk](https://ttdl.orlovsky.dev/reference/archive-layout/) ·
  [The state files](https://ttdl.orlovsky.dev/reference/state-files/) ·
  [Exit codes](https://ttdl.orlovsky.dev/reference/exit-codes/)
- **Explanation** — [The rate limit, measured](https://ttdl.orlovsky.dev/explanation/rate-limit/) ·
  [The author's card](https://ttdl.orlovsky.dev/explanation/authors-card/) ·
  [Notable decisions](https://ttdl.orlovsky.dev/explanation/decisions/) ·
  [Known limits](https://ttdl.orlovsky.dev/explanation/known-limits/)

## Alternatives considered

- [`JoeanAmier/TikTokDownloader`](https://github.com/JoeanAmier/TikTokDownloader) — 15k stars, CLI +
  WebUI + Docker, handles likes, collections, comments, live streams, exports to CSV/XLSX/SQLite,
  and unlike yt-dlp it does carousels. Worth it if you want analytics; overkill for archiving video.
- [`dinoosauro/tiktok-to-ytdlp`](https://github.com/dinoosauro/tiktok-to-ytdlp) — browser extension
  that collects links into a txt for `yt-dlp -a`. Useful for your own likes and bookmarks, which
  yt-dlp can't reach.
- Puppeteer/Selenium scrapers — drive a real browser through the profile. Slow and brittle; only
  worth it if the API route closes entirely.
