A self-hosted web app that shows what to watch next across Trakt, Simkl and MyAnimeList, and makes disagreements between them visible. Single user.
Each source keeps its own title, progress and next episode, side by side. No source is treated as correct, nothing is merged or resolved automatically, and nothing is written to a source without a per-source preview and your confirm.
Built in steps tracked as GitHub issues step-1 to step-7 (order in docs/plan.md).
| Step | What | State |
|---|---|---|
| 1 | SQLite schema, token encryption, source wrapper (429 handling, throttle) | done |
| 2 | OAuth for Trakt, Simkl and MAL, with refresh | done |
| 3 | Read-only fetch of the three watching lists | done |
| 4 | Mapping: links by ID, anime season chains, Trakt search, /mappings |
done |
| 5 | Up Next page with one column per source | done |
| 6 | Mark next episode watched, with preview and write log | done |
| 7 | Deploy with Docker Compose behind Cloudflare Access | done |
What works today: connect the three sources on /settings, line your shows up across them on /mappings, and see what to watch next on /, with each source's own progress and any difference flagged. A difference you accept stays hidden until a source moves. Mark the next episode watched from there, with a preview per source; every write is listed on /log.
- Node
^22.21.0,^24.11.0or>=26.0.0. Node is the runtime. - Vite+ (
vp), the only tool chain: it picks the Node version, runs pnpm, and formats, lints, type checks and tests. Install it withcurl -fsSL https://vite.plus | bash. - Accounts on Trakt, Simkl and MyAnimeList, and a GitHub account (Trakt requires one to create apps).
git clone git@github.com:oowais/tsuzuku.git
cd tsuzuku
vp install
vp test runbetter-sqlite3 ships prebuilt binaries for common platforms, so no compiler is needed.
Each source needs its own app with a redirect URL that matches exactly. For local development:
| Source | Where | Redirect URL | Notes |
|---|---|---|---|
| Trakt | trakt.tv/oauth/applications | http://localhost:3000/api/auth/trakt/callback |
Link GitHub first. New apps use PKCE and get no client secret; only the Client ID is needed. The localhost warning is fine for development. Leave scopes and permissions off. |
| Simkl | simkl.com/settings/developer | http://localhost:3000/api/auth/simkl/callback |
Create an AUTH V2 app of type Server apps & services (Client ID + secret, PKCE). The secret is shown once: copy it straight into .env. |
| MyAnimeList | myanimelist.net/apiconfig | http://localhost:3000/api/auth/mal/callback |
App type web. Homepage http://localhost:3000. |
For a deployed instance, add the same paths on your own domain (https://<your-domain>/api/auth/<source>/callback) and set APP_URL to match.
Copy .env.example to .env and fill it in. .env is gitignored; never commit it.
TOKEN_ENC_KEY encrypts the source tokens stored in SQLite. Generate 32 random bytes in base64:
openssl rand -base64 32On Windows PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
Back this key up separately from the database. Without it the stored tokens are unreadable and every source has to be connected again.
| Variable | Needed for |
|---|---|
TOKEN_ENC_KEY |
Always |
APP_URL |
OAuth redirect URLs. http://localhost:3000 for development |
DATABASE_PATH |
Optional, defaults to .data/tsuzuku.db |
TRAKT_CLIENT_ID |
Trakt (there is no secret) |
SIMKL_CLIENT_ID, SIMKL_CLIENT_SECRET |
Simkl |
MAL_CLIENT_ID, MAL_CLIENT_SECRET |
MyAnimeList |
TMDB_API_KEY |
Movie search on the Movies page (TMDB API key or read access token); without it only anime movies can be searched |
vp run devOpen http://localhost:3000/settings and connect Trakt, Simkl and MyAnimeList. Then open http://localhost:3000/mappings to confirm how your shows line up.
Database migrations run when the server starts. Restart vp run dev after pulling changes that add a migration (server/db/migrations/).
To try the UI without real accounts, or to check a change on every case:
vp run dev:demo- Fictional shows and anime (
server/demo/fixtures.ts), answered by a fake Trakt, Simkl, MAL and AniList inside the dev server (server/demo/fake-sources.ts). The real adapters, parsing and mapping run; nothing leaves the machine. - A separate database,
.data/demo.db, filled on first start: links, a proposal waiting on/mappings, an accepted difference and a write log. No token is stored, and connecting a source is off. - Every Up Next case appears: all sources agree, differs, accepted, caught up, not on a list, not linked, Trakt season not set, only on Trakt, an OVA, and an episode that airs in a few days.
- Marking watched works: the fake sources apply it, so the row moves on. They start over when the server restarts.
DEMO_FAIL=mal vp run dev:demo(orsimkl,trakt, comma-separated) makes those sources answer with a rate limit, to see "blocked" and stale data.- To start over, delete
.data/demo.db. Demo mode refuses to run withNODE_ENV=production, which the Docker image sets.
One container on a home server, reachable only through an existing Cloudflare tunnel whose cloudflared runs in its own container. Tsuzuku joins that container's Docker network and publishes no port.
git clone https://github.com/oowais/tsuzuku.git
cd tsuzuku
cp .env.example .env
mkdir dataFill in .env as in setup step 3, with:
APP_URL=https://tsuzuku.<your-domain>TUNNEL_NETWORK=<network>: a Docker network shared with cloudflared. Give Tsuzuku one of its own and let cloudflared join it, so Tsuzuku sees no other app's containers:docker network create tsuzuku, and setTUNNEL_NETWORK=tsuzuku.- In cloudflared's compose file, add
tsuzukuto the service'snetworksand declare it at the bottom astsuzuku: {external: true}, thendocker compose up -dthere (the tunnel drops for a few seconds). - Check:
docker inspect <cloudflared container> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'liststsuzuku.
- The same
TOKEN_ENC_KEYas your local.envif you bring your database along (step 2).
The container runs as user node (uid 1000). If your user on the server has another uid, run sudo chown 1000:1000 data.
It keeps your links, rejected matches, accepted differences, the write log and the source connections. On your dev machine, with or without the dev server running:
sqlite3 .data/tsuzuku.db ".backup tsuzuku-copy.db"
scp tsuzuku-copy.db <server>:tsuzuku/data/tsuzuku.dbAfterwards stop using those connections locally: stop vp run dev and move .data/ aside (mv .data .data-before-deploy). There is no disconnect button, and both copies hold the same tokens: Trakt and MAL issue a new refresh token on every refresh, so whichever copy refreshes second is locked out. Connect sources fresh in dev if you need them there; that creates separate tokens.
CI builds the image on every push to main and publishes it to GHCR as a private package, ghcr.io/oowais/tsuzuku, tagged latest and with the short commit. The server pulls it; nothing is built there. The first publish takes the repository's visibility, so after it open the package's settings (your profile → Packages → tsuzuku) and set it to Private; later pushes keep that.
Once per server, log in with a classic personal access token (GHCR does not take fine-grained ones) that has only the read:packages scope: GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic). Paste it at the password prompt:
docker login ghcr.io -u oowaisThen:
docker compose pull
docker compose up -d
docker compose psps shows healthy once the server answers. Migrations run at startup. To update later: git pull (for changes to compose.yaml), then the same pull and up -d. To go back a version, set image: in compose.yaml to a commit tag such as ghcr.io/oowais/tsuzuku:c444b45; the newest 10 are kept.
When the token expires, pull fails with unauthorized: make a new one and log in again.
In the Zero Trust dashboard:
-
Networks → Tunnels → your tunnel → Public hostname:
tsuzuku.<your-domain>, servicehttp://tsuzuku:3000. -
Access → Applications → Add → Self-hosted: the same hostname. Policy Allow, rule Emails = your email. Login method: one-time PIN.
-
Optional, TOTP on top of the PIN: in Access settings, allow MFA with Authenticator application. Enable the App Launcher (same policy), open
https://<team>.cloudflareaccess.comand enroll your authenticator. Then on the app: Login methods → MFA → Customize MFA settings, authenticator application, 24 hours. A long app session (for example8760h) then means the PIN is rare and TOTP is the daily check. -
The app checks Access too. Copy the application's Overview → Application Audience (AUD) Tag into the server's
.envasCF_ACCESS_AUD, and your team domain (Settings → Team name and domain,<team>.cloudflareaccess.com) asCF_ACCESS_TEAM_DOMAIN. Without both, the container will not start; with them, any request that did not come through Access gets a 403 (logged as[access] Refused …).
Open https://tsuzuku.<your-domain>: Cloudflare asks for the PIN first, then the app loads.
Add the production redirect URLs next to the local ones, on each source's app page:
https://tsuzuku.<your-domain>/api/auth/trakt/callbackhttps://tsuzuku.<your-domain>/api/auth/simkl/callbackhttps://tsuzuku.<your-domain>/api/auth/mal/callback
If a source's form takes only one redirect URL, create a second app for production, put its ID and secret in the server's .env, and connect that source again on /settings.
In the app: one is made every night at 03:00 in the server's time zone (UTC in the container unless TZ is set in .env). Settings → Backups → Back up now makes one at any time. Each nightly run leaves a [backup] line in docker compose logs tsuzuku, and the start-up log says when it runs. Each is a copy made with SQLite's online backup, safe while the app runs, into data/backups/. The newest 14 are kept and listed with a Download button, which is the easy way to get a copy off the machine. Only names in that list are served.
From the shell: scripts/backup.mjs makes the same copy:
docker compose exec -T tsuzuku node scripts/backup.mjsCopy data/backups/ off the machine now and then. Keep TOKEN_ENC_KEY in your password manager, never next to the backups: a backup without the key still has your links and log, but the source connections have to be made again.
Quick check without the tunnel: uncomment the ports lines in compose.yaml (127.0.0.1:3000:3000), run up -d, and open http://localhost:3000 on the server. If cloudflared runs on the host instead of in a container, use that and point the tunnel at http://localhost:3000.
| Command | What |
|---|---|
vp run dev |
Dev server on port 3000 |
vp run dev:demo |
Dev server in demo mode: fictional shows, fake sources, no accounts needed |
vp test |
Unit tests (Vitest), in watch mode; vp test run runs once |
vp check |
Format check, lint and type check (oxfmt, oxlint); --fix fixes |
vp run typecheck |
Type check with Vue templates (nuxt typecheck) |
vp run db:generate |
Generate a migration after changing server/db/schema.ts |
vp run dev and vp dev differ: vp dev is Vite's own dev server, not Nuxt's. A pre-commit hook (.vite-hooks/, set up by vp install) runs vp check --fix on staged files.
CI runs vp check, typecheck and tests on every push, then builds and starts the Docker image; on main it also publishes the image to GHCR. A push that changes only Markdown or docs/ skips CI.
- Stack: Nuxt 4 and Nuxt UI, SQLite in WAL mode with Drizzle, TMDB and AniList for metadata.
- Source wrapper (
server/lib/source-wrapper.ts): every call to Trakt, Simkl, MAL, TMDB or AniList goes through it. It throttles per source, honours429/Retry-After, and serves the last good response marked stale when a source is blocked or failing. One failing source never blanks the page. - Live, not synced: lists are fetched when you open a page. Simkl is checked through
/sync/activitiesfirst, as its API rules require, and the lists are fetched only when something changed. There is no background sync and no job queue. - Mapping (
server/lib/mapping*.ts,server/lib/seasons.ts): links by shared IDs first, then AniList prequel chains for anime seasons, then title matches and search. Anything not proven by an ID waits for your confirm. Only real IDs returned by a source are stored. - Links: every title, season and episode links to that source's own page.
- docs/decisions.md: the locked decisions. Change one only on purpose.
- docs/plan.md: architecture, data model, adapters, mapping, UI, auth, build order.
- docs/context.md: rejected options and why, and what is known about each API from real responses.
- CLAUDE.md: rules for AI-assisted work on this repo.
- The database is
.data/tsuzuku.db(gitignored). Back it up withsqlite3 .backupor Litestream, not a raw copy while the app is writing. - Source tokens are encrypted with
TOKEN_ENC_KEY. Keep the key outside the database and back it up separately. - Secrets are never logged. The OAuth log lines show token response field names only.