Contributing
This page is for someone who wants to change the code. After reading it you will be able to set up a development environment, find your way around the tree, run the API, the worker and the console locally, run every layer of the test suite, and get a change past the gates CI enforces.
What you need
Section titled “What you need”| Tool | Version | Needed for |
|---|---|---|
| Python | >=3.12,<3.14 |
The pin in pyproject.toml. The container image builds on 3.12.8 (PYTHON_VERSION in docker/compose.yml). |
| uv | current | The only supported installer and runner. uv.lock is the pin; CI runs with UV_FROZEN=1. |
| Node.js | 22 | The console. That is the version the CI console job uses. |
| Docker + Compose v2 | current | PostgreSQL and Redis for the integration tests, and the full stack. |
| Go | 1.23 | Only if you touch the media downloader in docker/downloader/. |
There is no root requirements.txt and no setup.py (the browser-rpc sidecar keeps its own requirements.txt, since it ships as a separate image). Dependencies are declared in pyproject.toml and resolved into uv.lock; changing a dependency means changing both and committing the lock file, or CI fails at uv sync rather than at your code.
You do not need a headless browser to develop. browser-rpc is an optional compose profile, the signing default is the in-process native implementation, and the identity pool falls back to manual cookie import when no browser service is configured. See Identities and proxies.
Setting up
Section titled “Setting up”v5 is main, so a clone gives it to you by default.
git clone https://github.com/Evil0ctal/Douyin_TikTok_Download_API.gitcd Douyin_TikTok_Download_APIThe repository has three branches and they do different jobs:
| Branch | What it is | Takes pull requests |
|---|---|---|
main |
v5, the development trunk | Yes — every pull request goes here |
release |
The commit currently published as a Docker image | No; it is only fast-forwarded from main |
v4 |
The old code, frozen | Yes, but only for security and blocking fixes |
main and v4 share no common ancestor — v5 was rewritten from an empty branch — so merging or cherry-picking between them is not a thing that exists.
Python side:
uv sync --all-extrasmake install is the same command. --all-extras pulls the dev extra — pytest, pytest-asyncio, pytest-cov, ruff, mypy, types-redis — which nothing else installs.
Console side:
(cd web && npm ci)npm ci rather than npm install: it installs exactly what package-lock.json says, which is what CI does, and it will not quietly move a version underneath you.
Check that both halves work:
uv run dtk --version(cd web && npx tsc --version)Everything below assumes you are at the repository root unless a command says otherwise.
Repository layout
Section titled “Repository layout”Top level:
| Path | What belongs in it |
|---|---|
src/dtk/ |
The Python application. One installable package, dtk, declared in pyproject.toml as packages = ["src/dtk"]. |
tests/ |
The Python test suite, in four layers — see The test suite. |
web/ |
The React console. Built to static files and served by the API container; no Node in production. |
docker/ |
Images, compose files, and the two optional sidecars that live outside the dtk package: browser_rpc/ (Python, FastAPI + headless browser, its own dependency set) and downloader/ (Go). |
scripts/ |
smoke.sh, the full-stack end-to-end check. |
documents/ |
This documentation set, en/ and zh/. |
logo/ |
The project mark, as SVG and PNG. |
Makefile |
The short names for everything in this page. Read it when a command here surprises you — it is the source. |
alembic.ini |
Migration config. Deliberately carries no database URL; env.py reads DTK_DATABASE_URL. |
.env.example |
The bootstrap variables; the three secrets ship empty, everything else carries the default it documents. Your own .env is git-ignored and must stay that way. |
One path you will see cited and will not find: docstrings and comments across src/, web/, tests/ and docker/ — around 160 files — point at docs/design/NN-*.md, and .github/workflows/ci.yml does too. .gitignore lists docs/, so that directory is never distributed and your clone does not have it. Those files are the author’s working design notes; documents/ is the published equivalent, organized for a reader rather than file-for-file against them. A docstring citing docs/design/13-testing.md is not a broken link to fix — it is a note the author left for themselves.
Inside src/dtk/:
| Package | What belongs in it |
|---|---|
api/ |
FastAPI: the app factory, middleware, the response envelope, dependencies, and routes/ (with routes/admin/ for console-only routes). Also console.py, which serves the built SPA. |
cli/ |
The dtk command. One module per command group; runtime.py owns the per-invocation lifecycle. |
core/ |
Config, crypto, the engine and session, Redis, logging, error codes, shared enums. Everything else may import this; it imports nothing else. |
db/ |
ORM models, repositories, Alembic revisions under migrations/versions/, TimescaleDB helpers. |
i18n/ |
Backend catalogues and locale formatting. Chinese text lives in locales/*.json, never in Python. |
identity/ |
The identity pool, cookie import, the mint log, and the browser-rpc client. |
mcp/ |
The MCP server. It calls the service layer directly, not this project’s own HTTP API. |
media/ |
The client for the Go downloader sidecar, its CDN host allowlist, and the download plan. |
models/ |
The normalized domain models (Content, Author, Comment, Page) both platforms produce. |
ops/ |
Health, backup, notifications, diagnostics, retention, masking, webhooks — everything an operator needs and no diagram shows. |
platforms/ |
One subpackage per platform. Endpoint tables, parameter builders, parsers. No IO of any kind. |
scheduler/ |
Ranking, leases, token buckets, circuit breaker, per-endpoint policy. |
services/ |
The layer REST, MCP and the CLI all share: fetch, archive, downloads, tasks, watchlist, collections, cache, settings. |
signing/ |
The signature algorithms (native/), the browser fallback (rpc.py), and the registry that chooses between them. |
transport/ |
The wreq client, TLS/browser emulation, headers, and response classification. Nothing above this package imports wreq. |
urls/ |
URL recognition, id extraction, short-link expansion. Also the SSRF chokepoint — do not parse a platform URL anywhere else. |
worker/ |
The task loop, the background loops (pool filler, proxy prober, maintenance, watcher), ops/ for one-shot operations, and registry.py — the one table that says what douyin.content_detail means. |
Inside web/src/:
| Path | What belongs in it |
|---|---|
main.tsx |
Theme and i18n bootstrap, root render, document title. |
App.tsx |
The shell, the router, and the setup gate. |
pages/ |
One file per route. |
components/ |
The design-system inventory. Import from @/components. |
hooks/ |
Shared hooks. |
lib/ |
API client, endpoint paths, formatting, i18n, theme, navigation. |
locales/{en,zh}/ |
common, console, errors, setup. The only place Chinese text may live. |
styles/ |
tokens.css, base.css, utilities.css. The only place a colour literal may live. |
@/ resolves to web/src/. TypeScript is strict, plus noUncheckedIndexedAccess and verbatimModuleSyntax — index access is T | undefined and type-only imports need import type.
The services you develop against
Section titled “The services you develop against”Postgres and Redis in the production compose file sit on an internal: true network and publish no ports, so a process on your host cannot reach them. Development uses a second, separate compose project instead:
make fixtures-upwhich is:
docker compose -p dtk-test -f docker/compose.test.yml up -d --wait| Service | Address | Credentials |
|---|---|---|
PostgreSQL (timescale/timescaledb-ha:pg17) |
127.0.0.1:55432 |
user dtk, password dtk_test_password, database dtk_test |
| Redis 8 | 127.0.0.1:56379 |
no password |
Both are bound to loopback. The integration suite reads those addresses from DTK_TEST_DATABASE_URL and DTK_TEST_REDIS_URL and falls back to exactly the values above, so point them somewhere else if you already run Postgres and Redis locally.
These fixtures are disposable. make fixtures-down runs down -v, which removes the volumes with the containers, and the Redis service runs with persistence switched off. Nothing you put in them survives. That is deliberate: a test fixture that accumulates state is a test fixture that eventually explains a failure that has nothing to do with your change.
Two consequences worth knowing before you use them as a development database:
- The integration suite clears every table before each test and flushes Redis. If you are developing against the same fixtures, running the suite will delete your data.
- The suite reserves Redis logical database 9 for the API tests, so it will not flush a neighbouring suite’s session mid-test. Your own process defaults to database 0.
The full stack is a different thing, and it is what Installation and deployment covers:
make up # docker compose -p dtk -f docker/compose.yml up -d --waitmake logsmake down # stops the stack, keeps the dataUse it to check that a change works in a real deployment, not as your edit-and-reload loop.
make clean is deliberately not in that block, and it is not the fixture command. It runs down -v --remove-orphans against the dtk project — the real stack — as well as against the test fixtures and against the dtk-smoke project scripts/smoke.sh leaves behind. So it removes every dtk container and network and the volumes: postgres-data, redis-data, backup-data and media-data. That is your instance’s database, its backups and its downloaded media, not just the disposable fixtures above. It is the same command Installation and deployment calls irreversible — copy any archive you want to keep off the backup volume first. make down is the one that stops the stack and leaves the data where it is.
Running the API and the worker
Section titled “Running the API and the worker”Three environment variables are read before the database exists, and the process refuses to start without the first one:
export DTK_SECRET_KEY=$(openssl rand -base64 48)export DTK_DATABASE_URL=postgresql+asyncpg://dtk:dtk_test_password@127.0.0.1:55432/dtk_testexport DTK_REDIS_URL=redis://127.0.0.1:56379/0DTK_SECRET_KEY must be at least 32 characters. It encrypts every stored cookie and proxy credential, so nothing ships a default; a key that is the same on every install is not encryption. Generate a throwaway one for development and do not reuse a production key.
A gotcha worth naming. BootstrapSettings reads a .env file from the current working directory as well as the environment. If you have written a repository-root .env for the compose stack, it names postgres:5432 and redis:6379 — container hostnames that do not resolve on your host. Real environment variables take precedence over the file, which is why the exports above are the reliable form.
Apply the schema, then run:
uv run dtk migrateuv run dtk serve --reloaddtk serve reads bind_host and bind_port from the bootstrap settings (127.0.0.1:8000 by default) and --host / --port override them. --reload cannot be combined with --workers; the command says so rather than starting something confusing.
The worker is a second process, and everything asynchronous — parses, downloads, watchlist collection, identity minting, backups — is dead without it:
uv run dtk workerIt is deliberately free of tuning flags. Concurrency and the background intervals belong to the process configuration, not to the invocation, so that a worker you start by hand during an incident behaves exactly like the one in the compose file. It drains on SIGTERM: claiming stops at once, in-flight work is given time to finish.
uv run alembic upgrade head is equivalent to dtk migrate and is what CI uses; alembic.ini carries no URL, and env.py reads DTK_DATABASE_URL (then DATABASE_URL) from the environment, so no connection string with a password can end up in a committed file.
If web/dist exists, the API serves the built console at /. If it does not — the normal state in a checkout — those routes simply do not exist, and you use the dev server instead.
Running the console dev server
Section titled “Running the console dev server”(cd web && npm run dev)That serves the console on http://localhost:5173 and proxies /api, /docs, /redoc, /openapi.json, /healthz and /readyz to http://127.0.0.1:8000 — the API process from the previous section. Nothing else is proxied.
| Variable | Effect |
|---|---|
DTK_API_TARGET |
Where the dev proxy sends those prefixes. Default http://127.0.0.1:8000. |
VITE_API_BASE_URL |
Only needed when the console is served from a different origin than the API. |
The other scripts, all run from web/:
| Command | What it does |
|---|---|
npm run dev |
Vite dev server on port 5173. |
npm run typecheck |
tsc --noEmit against both tsconfig.json and tsconfig.node.json. |
npm run lint |
ESLint with --max-warnings 0, then node scripts/check-i18n.mjs. |
npm run build |
Typechecks tsconfig.json, then emits web/dist. |
npm run preview |
Serves the built output. |
npm run verify |
typecheck, lint, build — the three CI runs, in CI’s order. |
The test suite
Section titled “The test suite”Four layers, and the split is what makes each one trustworthy. Configuration is in pyproject.toml under [tool.pytest.ini_options]: asyncio_mode = "auto" (async tests need no decorator), testpaths = ["tests"], and two declared markers.
| Layer | Path | Needs | Marker | Run with |
|---|---|---|---|---|
| Unit | tests/unit/ |
Nothing | none | uv run pytest tests/unit -q |
| Replay | tests/replay/ |
Nothing | none | uv run pytest tests/replay -q |
| Integration | tests/integration/ |
PostgreSQL + Redis | integration |
uv run pytest tests/integration -q -m integration |
| Contract | tests/contract/ |
A running deployment and the live platform | live |
uv run pytest tests/contract -m live -v |
The two markers are declared as:
integration: requires PostgreSQL and Redislive: hits the real platform; never runs in CI gating
Unit and replay
Section titled “Unit and replay”make test-unit # uv run pytest tests/unit tests/replay -qNo services, no network, no configuration. Unit tests cover the scheduler maths, the signing algorithms, URL parsing, the worker’s classification, the ops modules, the CLI, and a set of repository hygiene checks described under Code style.
Replay tests feed stored response bodies straight into the platform parsers — dict in, model out, no IO — which is only possible because parsers are pure functions. They also assert normalization parity: for the same logical object both platforms must produce the same field set, differing only in which fields are None. A per-platform test cannot notice when the two drift apart.
tests/fixtures/<platform>/ holds eleven JSON files per platform, one per response shape (a normal video, an image album, a deleted post, a private post, a long description, a profile, a post page, comments, replies, and two risk-control shapes). Read tests/fixtures/README.md before adding or replacing one — it carries the redaction rules, and tests/replay/test_platform_contract.py fails the build on a fixture containing a credential-shaped key.
Know what those fixtures currently are: every one of them was written by hand from V4’s parsing and request code, not captured from a live platform, and they are meant to be replaced by real captures before release. That is what the README says, and test_fixture_readme_states_they_are_hand_built pins the warning so it cannot quietly be edited away. Until the captures land, a green replay run means “the parsers do what we think the platform does”, not “the parsers match the platform”.
Integration
Section titled “Integration”make test-integrationwhich brings the fixtures up, runs uv run pytest tests/integration -q -m integration, and tears them down with down -v afterwards whether the suite passed or failed.
The first time you run the suite against fresh fixtures, apply the migrations first — CI does this in its own step, and make test-integration does not do it for you. tests/integration/test_migrations.py asserts the physical shape of the database (hypertables, chunk intervals, policies), which only the Alembic revisions produce:
make fixtures-up uv run alembic upgrade headuv run pytest tests/integration -q -m integrationEvery module in this layer sets pytestmark = pytest.mark.integration, so -m integration is a filter and not a requirement — but keep the mark on anything you add, because it is what lets a run exclude the layer entirely. If Postgres or Redis is unreachable, the fixtures in tests/integration/conftest.py skip with a message telling you to run make fixtures-up, rather than failing with a connection error.
tests/integration/test_api_support.py holds the shared fixtures for the API tests. Its TABLES tuple lists every table cleared before each test, children first, and tests/unit/test_repo_hygiene.py fails if a table exists in the schema and is missing from that list. Add a table to the model, add it to TABLES.
Contract
Section titled “Contract”These hit the live platform, and they exist to answer one question nothing else can: did the platform change? A replay test runs against a fixture frozen in the repository — hand-built today, a real capture later — so it keeps passing while production breaks.
DTK_CONTRACT_BASE_URL=http://127.0.0.1:8000 \DTK_CONTRACT_API_KEY=dtk_xxx_yyy \ uv run pytest tests/contract -m live -vWithout both variables every test skips, which is what happens in ordinary CI. tests/contract/subjects.py ships empty on purpose: a fresh clone has no test subjects, so the suite skips rather than failing at somebody else’s deleted video.
They deliberately do not gate merges. A contract test fails for reasons that have nothing to do with the diff under review — the platform hiccups, a proxy drops, the pool is empty — and making a pull request wait on that teaches everyone to ignore a red check. In .github/workflows/ci.yml the job carries continue-on-error: true and is gated on if: github.event_name == 'schedule' — but that workflow declares only push and pull_request triggers, so the condition is never true and the job never runs in CI at all. Today it is a by-hand suite; wiring it up means adding a schedule: trigger to the workflow.
Two suites that live outside tests/
Section titled “Two suites that live outside tests/”testpaths = ["tests"] means a bare uv run pytest does not collect either of these. Name the path.
uv run pytest docker/browser_rpc/tests -q # browser-rpc, against its fake backend; no browser needed(cd docker/downloader && go test ./...) # the Go media downloaderdocker/browser_rpc/tests/conftest.py puts docker/ on sys.path, so import browser_rpc works from a checkout even though that service ships as its own image with its own dependency set.
Coverage
Section titled “Coverage”CI collects coverage on the unit job with --cov=src/dtk --cov-report=term-missing. There is no minimum-coverage gate — nothing fails on a percentage. [tool.coverage.run] measures src/dtk and omits */migrations/*.
Quality gates
Section titled “Quality gates”These are the checks CI runs. All of them are runnable locally; only the image build needs the network, for the base images and the dependency downloads.
| Gate | Command | CI job |
|---|---|---|
| Format | uv run ruff format --check src tests |
static |
| Lint | uv run ruff check src tests |
static |
| Types | uv run mypy |
static |
| Unit + replay | uv run pytest tests/unit tests/replay -q |
unit |
| Integration | uv run pytest tests/integration -q -m integration |
integration |
| Console types | (cd web && npm run typecheck) |
console |
| Console lint + i18n | (cd web && npm run lint) |
console |
| Console build | (cd web && npm run build) |
console |
| Compose files parse | docker compose -f docker/compose.yml config -q, then docker compose -f docker/compose.test.yml config -q |
image |
| Image builds | docker build -f docker/Dockerfile -t dtk:ci . |
image |
The Python side has short names:
make fmt # ruff format, then ruff check --fixmake lint # ruff check, then ruff format --checkmake type # mypymake test # unit + replay, then integration with the fixtures managed for youmake fmt is the one that writes; run it before make lint and the format gate will pass.
An end-to-end check of the whole stack, which brings the compose project up, walks the initialization flow, mints an API key, exercises the documented contract and always tears itself down:
make smoke # ./scripts/smoke.sh./scripts/smoke.sh --keep # leave the stack running to poke atIt is not part of CI. It takes minutes and needs Docker, and it is worth running before a release or after a change to the compose files, the entrypoint or the setup flow.
What the tools are configured to do
Section titled “What the tools are configured to do”ruff ([tool.ruff]): line length 100, target py312, rule sets E, F, W, I (import sorting, dtk as first-party), N, UP, B, C4, SIM, RUF. Ignored: E501, B008, N818, RUF001, RUF002, RUF003.
Two of those are worth understanding rather than working around. E501 is off because the formatter owns line length at 100 — a long URL or a long comment is not a lint error. N818 wants an Error suffix on every exception class; the domain exceptions here are named after their stable wire error code (UpstreamChanged, SecretKeyMissing) so the class name matches the contract a caller branches on, and the base class carries the suffix.
mypy ([tool.mypy]): Python 3.12, packages = ["dtk"], mypy_path = "src", ignore_missing_imports = true, warn_unused_ignores = true. uv run mypy with no arguments checks the right thing.
It is not globally strict, and that is a deliberate gradient: three packages — dtk.platforms.*, dtk.models.*, dtk.scheduler.* — additionally set disallow_untyped_defs. Those are the ones where an untyped function is genuinely dangerous: the data contract both platforms have to satisfy, and the arithmetic that decides which identity gets used.
ESLint (web/eslint.config.js): the recommended JS and TypeScript sets, react-hooks, plus @typescript-eslint/no-explicit-any and no-non-null-assertion as errors and three rules written for this project — see the next section.
Code style
Section titled “Code style”Some of these are conventions and some are automated. Where a check exists it is named, because a rule with a check behind it is a rule you can rely on rather than one you have to remember.
Source is English
Section titled “Source is English”No CJK anywhere in src/dtk/**/*.py or in web/src/**/*.{ts,tsx} — comments, identifiers and strings alike. Chinese lives in src/dtk/i18n/locales/*.json and web/src/locales/zh/*.json, and nowhere else.
Checked twice: tests/unit/test_repo_hygiene.py::TestSourceLanguage on the Python side and the local/no-cjk-source ESLint rule on the console side. Writing a comment in your own language is always the path of least resistance, so only a machine check holds this line.
Tests may contain CJK only as input data — real users paste Chinese share text and the URL extractor has to cope with it. Three files are allowlisted for that: tests/unit/test_urls.py, test_i18n.py, test_identity_importing.py. Everything that is code — names, comments, assertions — stays English even there, and tests/unit/test_i18n.py writes its Chinese expectations as \uXXXX escapes with a comment saying what each one reads as.
The one exemption is the project’s mark, the ASCII cat that has sat at the top of this project’s entry points since v1 and is drawn partly in katakana. It is exempted by shape, not by filename, in tests/support/marks.py: a line passes only if every character in it comes from the mark’s own small alphabet, so a line that smuggles a real word in beside the cat is not exempt.
Every user-visible string comes from a catalogue
Section titled “Every user-visible string comes from a catalogue”On the console this is a lint rule, not a review note. local/no-untranslated-text reports literal prose in JSX text, in a set of translatable attributes (label, placeholder, title, description, hint, summary, caption, confirmLabel, …), in {cond ? 'Yes' : 'No'} expressions, and in object-literal properties with those names — because column and option tables are declared far from the JSX that renders them.
Identifiers are not prose and are exempt by pattern: SCREAMING_CASE codes, dotted setting keys and endpoint names, version numbers, IANA time zones, URLs, and anything inside <code>, <pre>, <kbd>, <samp> or an element with the u-mono / mono class. If your case is a genuine exception, add an eslint-disable-next-line with the reason. Never translate an error code, an enum value, a field name, an endpoint path or a config key — translate the display label instead.
Colours come from tokens
Section titled “Colours come from tokens”No hex literal in .ts or .tsx. local/no-raw-hex-color fails the build on one there, in a string or in a template literal, and tests/unit/test_repo_hygiene.py sweeps the same files as a backstop. ESLint never parses CSS here, so a hex literal in a .module.css is held by convention rather than by a check — the one deliberate exception in the tree is the white tile behind the sponsor mark, which is commented where it sits. Use var(--accent), var(--danger), var(--bg-raised).
Both themes are complete implementations rather than a base plus a filter, and tests/unit/test_repo_hygiene.py asserts that the dark and light blocks define exactly the same token names. A token defined in one and not the other breaks precisely one theme, quietly, in one colour.
Comments explain why
Section titled “Comments explain why”The code says what it does. A comment earns its place by recording the reason a decision was made — the measurement behind a number, the failure that produced a guard, the alternative that was tried and did not work. Much of this codebase’s commentary is of that shape, and it is what stops the next person “fixing” a deliberate trade-off. Where a number came from a measurement, say what was measured and when.
No leftovers in shipped code
Section titled “No leftovers in shipped code”tests/unit/test_repo_hygiene.py fails on TODO, FIXME or XXX anywhere in src/dtk/, and on NotImplementedError outside the two legitimate uses (an abstract base method, which is a contract, and contextlib.suppress(NotImplementedError, ...), which is how a caller copes with a platform that lacks a capability).
Secrets are never committed
Section titled “Secrets are never committed”.env and .env.* are git-ignored, with .env.example the one exception, and .env.example must ship its three secret variables empty — a test asserts it. Another scans the Python tree for anything shaped like a live session cookie. This is the V4 lesson: a real Douyin session sat in a tracked config.yaml and was one command away from being published.
Layering rules that are structural, not stylistic
Section titled “Layering rules that are structural, not stylistic”platforms/performs no IO. No HTTP, no configuration, no database. A platform package builds request descriptions and parses payloads; retries, signing, proxies, cookies, logging and caching all live above it. This is what makes the replay layer possible at all.- Parsers are pure functions.
dictin, model out. A missing required field raisesUpstreamChangedcarrying the dotted path that went missing; a payload that is a refusal raisesUpstreamRiskControl. Returning a half-filled model is never allowed. - Nothing above
transport/imports wreq. - Nothing outside
urls/parses a platform URL. That package is also the SSRF chokepoint for caller-supplied URLs. core/imports nothing else in the package. Everything else may import it.
Adding a platform endpoint
Section titled “Adding a platform endpoint”The example below adds a capability to an existing platform. Adding a whole platform is the same work plus a new src/dtk/platforms/<name>/ directory — and only that: adapters are discovered at import time by scanning for subpackages that expose adapter.ADAPTER, so there is no registry to edit. tests/replay/test_platform_contract.py asserts that property directly.
-
Declare the endpoint. In
src/dtk/platforms/<platform>/endpoints.py, add the URL constant, a stable name constant (douyin.author_followers—<platform>.<capability>), and anEndpointSpecentry in theENDPOINTStable. The spec carriesrequired, thebuildcallable, arisk_weight, and asummary.The name is part of the operational contract: the scheduler uses it verbatim as its Redis token-bucket and circuit-breaker key, the console shows it on the endpoint health board, and every row in
request_logcarries it. Do not rename one casually. -
Build the query string. Add the builder to
src/dtk/platforms/<platform>/params.py. Values are left un-encoded — percent-encoding happens once, in the transport layer, because the signature is computed over the encoded query string and encoding twice was a recurring source of V4 signature failures. Browser-shaped values (screen size, browser version, language) arrive as aClientProfilerather than being hardcoded, so that the query agrees with the identity’s User-Agent and TLS emulation. -
Parse the response. Add or extend a parser in
parser.pyand expose it on the adapter. Use the helpers insrc/dtk/platforms/common.pyso both platforms make the same decision about the same ambiguity: absent isNoneand never0or"", identifiers arestr, a missing required field raisesUpstreamChangedwith its path. -
Give it a scheduling policy. Add an
EndpointPolicytosrc/dtk/scheduler/policies.py. This is not optional in practice:tests/unit/test_policy_coverage.pyfails if a registered endpoint has no explicit policy, because a mismatch does not raise — it silently falls through toDEFAULT_POLICYand every tuned quota stops applying with nothing to say so. The same test rejects a policy naming an endpoint that no longer exists, and asserts the shape of a sane policy:max_concurrency == 1, refill at most 1/s, burst between 1 and 10,risk_weight >= 1.0.List endpoints are throttled harder than detail lookups, and a test asserts that too. Enumerating an author’s whole catalogue is the pattern platforms watch for; one detail lookup is the cheapest call a real user makes.
-
Wire the caller-facing name. In
src/dtk/worker/registry.py, add theCapabilityvalue if it is new, then an entry in_ARGUMENTSmapping canonical parameters (content_id,author_id,unique_id,mix_id,comment_id,cursor,count) onto that platform’s own argument names, and an entry in_CACHE_TTL_KEYS. The registry checks your mapping against the real builder signature at import time, so a typo is an import error rather than a runtime one.Capabilities in
P0_CAPABILITIESare required of every platform. Everything else is optional and declared per platform, because the platforms genuinely differ — TikTok exposes playlists as their own list, Douyin serves an author’s likes only to a signed-in identity. The endpoint health board,GET /api/v1/admin/endpoints/health, lists every declared endpoint per platform, so a console operator can see which capability exists where; an ordinary caller discovers it from theUNSUPPORTED_CONTENTerror, whosedetails.supportednames the platforms that do serve the operation. -
Expose it. A REST route in
src/dtk/api/routes/content.py, declared withopenapi_extra={I18N_KEY: "<key>", **ASYNC_RESPONSES}, and catalogue prose atopenapi.op.<key>.summary/.descriptionin bothsrc/dtk/i18n/locales/en.jsonandzh.json.tests/unit/test_i18n.pyandtests/unit/test_openapi_completeness.pyfail on an operation with no prose, a parameter with nodescription=, or a Chinese document that is still in English. The route function’s docstring is what FastAPI uses as the English description.MCP is a separate decision. The tool set is capped at eight on purpose — every extra tool measurably lowers an agent’s accuracy at choosing one — so a new capability should be a parameter of an existing tool rather than a ninth. See MCP and AI agents.
-
Add fixtures and a replay test. One JSON file per platform under
tests/fixtures/<platform>/, redacted per that directory’s README, and a test intests/replay/. When an endpoint breaks in production the first step is to save that response here and write a failing replay test, then fix the parser — so every incident leaves a permanent regression guard behind instead of being forgotten.
Adding a console page
Section titled “Adding a console page”web/src/pages/<Name>.tsx, default-exporting the component. Start with aPageHeaderand build outwards; use the inventory in@/componentsrather than hand-rolling a table or a dialog.- A
lazy()import and a<Route>inweb/src/App.tsx. Every page is code-split. - An entry in
NAV_ITEMSinweb/src/lib/nav.ts:path,labelKey, agroupfromNAV_GROUPS(monitor,pool,tools,access,operations), and aniconfromNavIconName. The icon name must exist in theNAV_ICONSmap inweb/src/components/Sidebar.tsx. A page that is real but has no place in a group goes inUNLISTEDinstead, so the breadcrumb can still name it. - Translation keys in both
web/src/locales/en/console.jsonandweb/src/locales/zh/console.json:nav.<key>for the sidebar label, andpage.<key>.titleandpage.<key>.descriptionfor the header. Add page-specific keys in the same commit —npm run lintfails on at()call naming a key no catalogue has, and on any literal prose you left in the JSX.
Conventions the design system expects, and which reviewers will hold you to:
- Status is colour + icon + text. Use
<StatusBadge kind="…" value={…} />rather than colouring a cell.business_errorrenders muted, never red: a deleted video is not a system fault. - Ids and JSON are mono.
<CopyableId>for an id,<CodeBlock json={…}>for a payload. - Polled values never animate. Pass
flashValuetoDataTableso a row highlights once when its status changes; do not add transitions that fire on every refresh. - Focus rings stay. No
outline: nonewithout a visible replacement. Everything reachable and operable by keyboard, overlays close on Esc. - Give
DataTableastorageKeyso the viewer’s column and density choices persist. - Poll at
POLL.fast/POLL.normal/POLL.slow. Do not invent an interval. - It must stay usable on a phone. Below 768px the sidebar becomes a drawer and tables become cards. That is where the 3am alert lands.
Adding a setting
Section titled “Adding a setting”Runtime settings are seeded from the environment once at first init and then live in the settings table, editable from the console without a restart. There are currently 54 of them across 14 groups. Full reference: Configuration reference.
-
Declare it in
RUNTIME_SETTINGSinsrc/dtk/core/config.pyas aSettingSpec: key (<group>.<name>), default,Scope, type, and a developer-facing description. Anything not in this registry cannot be stored, which is what keeps a typo from silently becoming a config key.Pick the scope honestly.
RUNTIMEis the ordinary case.SENSITIVEmeans widening the value enlarges the attack surface — the CORS origins, the URL allowlist, the task webhook switch.BOOTSTRAPandENV_ONLYare environment-only and are not part of this flow.Use
choicesfor a constrained string, andvalidatefor a constraint the type cannot express. A validator returns the value to store, so it may canonicalize as well as refuse — and it should refuse, by name, rather than normalize a nonsense value into a plausible one._percentrejects anything outside 1–99 because 0 would pause an instance the moment it starts and 150 would mean the guard never fires;_byte_ceilingallows 0 for “no limit” but refuses anything under 1 MiB. -
Explain it in both languages. Add
settings.description.<key>tosrc/dtk/i18n/locales/en.jsonandzh.json. This is not optional:tests/unit/test_i18n.pylists every setting with no catalogue description and fails, and a second test fails if the description is the humanized key echoed back. The registry’s own description is a note to developers; it is not the text a user reads. -
If the group is new, add it to
GROUP_ORDERinweb/src/pages/Settings.tsx— or toSECTIONSinweb/src/pages/Scheduler.tsxif it belongs to the scheduler, whereschedandpoolrender beside the pool they act on. Then addsettings.group.<group>andsettings.groupHint.<group>to both console catalogues. Without this the setting renders under “Other” with no heading of its own, andtests/unit/test_repo_hygiene.pysays so. -
If it has
choices, addsettings.choice.<key>.<choice>for every choice in both console catalogues. A picker whose options are bare keys makes the reader guess what they do, and a test enforces it.
Adding a translated string
Section titled “Adding a translated string”Two catalogue systems, and they do not overlap.
Backend, in src/dtk/i18n/locales/:
| File | Holds |
|---|---|
en.json, zh.json |
Everything the API renders as prose: setting descriptions, OpenAPI operation summaries and descriptions, parameter and request-body field text, diagnose actions, state and outcome labels. |
errors.en.json, errors.zh.json |
One message template per ErrorCode. Keyed by the code name. |
format.en.json, format.zh.json |
Locale formatting: count scales (K/M/B versus 万/亿), relative-time wording, duration patterns. |
Console, in web/src/locales/{en,zh}/: four namespaces — common, console, errors, setup.
Rules that hold on both sides:
- The key sets must match exactly between
enandzh. A missing key falls back to English silently and nobody notices, so it is a failure and not a warning. - Placeholders must be identical across languages, and console messages must parse as ICU in both.
- A translation must not be a copy of the English text. A test looks for that.
- Never translate an error code, an enum value, a field name, an endpoint path or a config key. Translate the label that displays it.
(cd web && npm run lint) runs web/scripts/check-i18n.mjs, which checks all of the above plus two things worth knowing about:
- Language names in the switcher are endonyms, identical in every catalogue. A reader who cannot read the current language is scanning the picker for the name of their own; translating that name is exactly what hides it from them.
- Every literal key a
t()call names must exist. Parity says the two files agree; it says nothing about whether the console asks for keys either of them has. A typo ships silently and renders as the raw key in the middle of a drawer — which this check exists because of. Keys built from a variable cannot be resolved and are skipped; a call site that passesdefaultValueis saying the key may be absent and is allowed.
On the Python side, tests/unit/test_i18n.py enforces the same parity, checks that every ErrorCode is translated in every language, and asserts that the locale files stay well-formed JSON. Runtime policy is the opposite of CI policy on purpose: a gap logs a warning and falls back to English rather than refusing to boot, because these files ship as editable JSON in a self-hosted install and a translator’s typo must cost one sentence, not the instance.
Adding an error code
Section titled “Adding an error code”An error code is a wire contract, so it touches four places, and a check covers all but the first:
ErrorCodeinsrc/dtk/core/errors.py, plus its entry inHTTP_STATUS, plusNON_RETRYABLEif retrying it can never help. Nothing testsHTTP_STATUSmembership: a code missing from it raisesKeyErrorthe first time anything asks for its status, at runtime. Add the entry in the same edit as the enum member.src/dtk/i18n/locales/errors.en.jsonanderrors.zh.json.code.<CODE>andhint.<CODE>inweb/src/locales/en/errors.jsonandzh/errors.json.ERROR_CODESinweb/src/lib/api.ts.isErrorCode()gates on that array, so a code missing there is silently rewritten toINTERNAL— the console then shows “an unexpected internal error” for a condition the server explained precisely.
check-i18n.mjs parses the enum straight out of errors.py and fails on a gap in 3 or 4 — and in 4 in either direction: a code with no console copy, and a console code the API never sends. It reads only web/src/locales/, so the backend catalogue (2) is not its business; tests/unit/test_i18n.py covers that one.
Commit messages
Section titled “Commit messages”Conventional-commit prefixes, with an optional scope: feat, fix, docs, test, chore, refactor, perf, ci. Scopes in use include console, signing, scheduler, services, db, api, i18n, core.
The subject line is lowercase after the prefix and says what changed in terms of behaviour, not which files moved:
fix: a post that does not exist is an answer, not risk controlfeat: show what the refill job is doing, failures includedfeat(console): add an MCP page to the consoleThe body is where this project’s commit history earns its keep, and long bodies are normal here. Say what you measured, what you tried, what you deliberately did not change, and what the failure mode was if the change is a fix. If a number in the diff came from a measurement, put the measurement in the message: two identities, two responses, the sizes, the date. A reviewer reading a diff six months later cannot re-derive that, and a comment in the code cannot carry all of it.
Opening a pull request
Section titled “Opening a pull request”Pull requests target main. CI runs on push and pull_request against it, and also checks pull requests aimed at v4 — that branch is frozen but still takes security fixes, and accepting a change without checking it would be worse than not accepting it.
Pushing to main does not build a Docker image. Publishing is its own act: fast-forward main to release, or tag v*. That way latest names a commit somebody decided to publish rather than whatever was merged an hour ago.
Opening a pull request gives you a bilingual template with this list in it. If you would rather have one language, append ?template=en.md or ?template=zh.md to the compare URL. Before you open one:
make fmt— then the format gate passes rather than failing on whitespace.make lint && make type— ruff and mypy.make test— unit, replay and integration, with the fixtures managed for you.(cd web && npm run verify)— if you touched anything underweb/.- Update the user documentation in
documents/en/anddocuments/zh/if you changed something a reader would act on: a setting, a CLI command, an endpoint, a console page, a default. The two language versions are the same document and are expected to stay in step. - Check that nothing you added is CJK in source, a hex colour outside the tokens file, a literal string in JSX, a
TODO, a.env, or a fixture with a real cookie in it. The hygiene tests will catch all six, and it is quicker to notice yourself.
In the description, say what the change does and why, and how you verified it. If you could not verify something — a code path that needs the live platform, a browser backend you do not have installed — say that too. An honest “not verified against a real browser” is worth more than a confident claim a reviewer has to disprove.
CI has five gating jobs: static, unit, integration, console, image. The sixth, contract, never blocks a merge — it is gated on a schedule trigger the workflow does not declare yet, so it does not run in CI at all and you run it by hand.
Related pages
Section titled “Related pages”- Installation and deployment — the compose stack you will be testing against.
- Configuration reference — every setting, its scope and its default.
- Concepts — identities, the scheduler, outcomes, the circuit breaker.
- REST API guide — the envelope, scopes,
?wait=,?refresh=,?explain=. - MCP and AI agents — the eight tools and why there are only eight.
- CLI reference — every command, and which ones are safe against a live instance.
- Troubleshooting — reading a failure before you conclude it is a bug.
- Security — the master key, the allowlists, what leaves the instance.