Files
jellytau/CLAUDE.md
dtourolle 96abc3afef docs(specs): make "a spec becomes an architecture doc" the written rule
The sixteen specs folded in last commit were folded because someone noticed
they had gone stale, not because anything said they should be. Without the rule
written down the directory drifts straight back to a mix of promises and
descriptions, and neither can be trusted: you cannot tell from a file whether it
describes the build or proposes a change to it.

So: docs/specs/ holds only unshipped work, there is no "Implemented" resting
state, and the fold-in and the deletion happen in the same commit.

The template now asks for the destination architecture doc **up front**, which
is a design check rather than bookkeeping — a feature that fits no existing doc
usually has an unclear layer assignment, and it is cheaper to find that out at
spec time. It also tells the author which half of what they are writing is
durable (invariants, rejected alternatives, the defect a decision prevents) and
which half dies with the file (phases, migration steps, acceptance criteria).

The review checklist gains a Lifecycle section, including the case that gets
lost otherwise: out-of-scope work worth doing has to be written where it will
still be found after the spec is gone.
2026-08-21 18:29:09 +02:00

18 KiB

JellyTau

A cross-platform Jellyfin client. Business logic lives in a Rust backend (src-tauri/); a SvelteKit + TypeScript frontend (src/) handles presentation and talks to it over Tauri v2 IPC. Targets Linux (libmpv, WebKitGTK HTML5 <video> for transcoded playback) and Android (ExoPlayer).

Package manager is bun.

Build / Run / Test

All routine tasks go through package.json scripts and helper scripts in scripts/:

bun install                  # install deps
bun run dev                  # vite dev server (frontend)
bun run tauri dev            # run the desktop app

bun run check                # svelte-check (types)
bun run test                 # vitest (frontend unit/integration)
bun run test:rust            # cargo test (scripts/test-rust.sh)
bun run test:all             # full suite (scripts/test-all.sh)
bun run lint                 # eslint (src/, scripts/, root configs)
bun run format:check         # prettier

# Android — canonical entry points (see scripts/):
bun run android:build            # debug APK
bun run android:build:release    # release APK
bun run android:deploy           # install to connected device
bun run android:dev              # build + deploy
bun run android:logs             # logcat

The debug build type carries applicationIdSuffix ".debug", so com.dtourolle.jellytau.debug ("JellyTau Debug") installs alongside a release build with its own data dir — never uninstall the release app to test a debug one. ./scripts/build-and-deploy.sh release --device --debug puts an R8-minified release build in that same slot, signed with the local debug keystore, for validating minification without the real key. Only the applicationId is suffixed; Kotlin classes stay in the namespace package com.dtourolle.jellytau, so JNI lookups and R8 keep rules are unaffected. See README_ANDROID_BUILD.md.

CI runs on Gitea Actions (.gitea/workflows/), not GitHub. Use the gh CLI only against the mirror if one exists; the canonical remote is gitea.tourolle.paris.

🔴 CI installs no system tools. Never add an apt-get, rustup, sdkmanager, mingw/nsis, or any other toolchain/system-package install to a CI workflow step. Every build, test, and packaging tool must already live in the Docker image the job runs in — the unified builder (Dockerfile.buildergitea.tourolle.paris/dtourolle/jellytau-builder) for Android/Linux/Windows, or Dockerfile.arch for Arch. If a job needs a tool the image lacks, add it to the image, rebuild + push it (scripts/build-builder-image.sh), and use it from CI — do not install it at job time. This keeps builds reproducible and fast, and is why the packaging stages are thin FROM ${BUILDER_IMAGE} layers.

bun install (fetching the project's own JS deps per the lockfile) is not a violation — that's project dependencies, not a toolchain. The rule is about system tools, not npm/bun/cargo packages declared by the project.

Before Committing

  • Frontend: bun run check, bun run test, bun run format:check and bun run lint (0 errors; the warning count is a CI ratchet) must pass.
  • Rust: cd src-tauri && cargo fmt then cargo clippy, plus bun run test:rust.
  • Boundary: bun run check:boundary must pass — no domain taxonomy (Jellyfin item-type category sets) leaked into the frontend. See below.
  • Traceability: new requirement-implementing code must carry a // TRACES: comment (see below).
  • Android source edits: edit src-tauri/android/src (the canonical tree), then run scripts/sync-android-sources.sh to sync into the gen/ tree. Never edit the generated gen/ sources directly.

Traceability (TRACES)

This project practices requirement-driven development: code that implements a requirement is tagged with a TRACES: comment linking it to requirement IDs, and an extraction tool builds the traceability matrix. When you add or change code that implements a requirement, add/update its TRACES comment. Internal helpers and requirement-less code stay untraced.

Format — // TRACES: <URs> | <DRs> | <tests>, e.g.:

/// TRACES: UR-005 | DR-001
pub enum PlayerState {  }
// TRACES: UR-005, UR-026 | DR-029
export function autoplayNextEpisode() { }

ID types: UR user requirement, IR integration, DR development, JA Jellyfin API, UT unit test, IT integration test. Requirements are defined in docs/requirements.md; the generated matrix is docs/traceability.md.

Tooling:

bun run traces            # extract traces (default format)
bun run traces:json       # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown   # regenerate docs/traceability.md
bun run traces:coverage   # coverage gate — exits non-zero below the threshold
bun run traces:validate   # dangling-ID gate — every traced ID must be defined
git diff --name-only | xargs grep -L "TRACES:"   # find untraced changed files

Every ID a TRACES: comment names must exist as a table row in docs/requirements.mdtraces:validate fails otherwise, so a typo or a rename that missed a call site can no longer pass silently.

CI is Gitea Actions (.gitea/workflows/, remote gitea.tourolle.paris), not GitHub. traceability-check.yml fails the build if coverage drops below 89% (MIN_THRESHOLD, a ratchet — raise it as coverage climbs, never lower it to make a build pass) or if any traced ID is undefined; build-and-test.yml runs frontend tests with coverage thresholds, bun run check, format:check, a --max-warnings eslint ratchet, Rust tests, cargo fmt --check, cargo clippy -D warnings, and an Android cargo check. See docs/traceability-ci.md and docs/traces-quick-ref.md.

Traces drive release notes

Prefer traceability over raw commit subjects when writing release notes for docs/release-checklist.md. Raw git log subjects are noisy; the TRACES graph gives a semantic summary of what capabilities the release touched.

bun run release:notes                 # <latest tag>..HEAD
bun run release:notes v0.0.15..HEAD   # explicit range

scripts/release-notes.ts resolves a commit range's changed files → their TRACES: IDs → descriptions in docs/requirements.md, then groups UR into Features and DR/IR into Improvements (deduped, so many commits touching one requirement collapse to one line). It also lists changed files that carry no TRACES so nothing is silently dropped — those still need a manual line. Treat the output as a reviewed draft, not a final changelog.

Architecture

  • Rust backend (src-tauri/src/) — all business logic: auth, catalog, sessions, downloads, offline cache, playback control. Commands grouped by domain in src-tauri/src/commands/ (auth.rs, catalog.rs, player/, download/, offline.rs, sessions.rs, …).
  • Svelte frontend (src/) — presentation only. Stores in src/lib/stores/, API wrappers in src/lib/api/, components in src/lib/components/.
  • Playback layers — Linux uses libmpv for direct playback and a WebKitGTK HTML5 <video> element for HLS-transcoded (h264) streams; Android uses ExoPlayer with a foreground media service + MediaSessionCompat.
  • tauri-specta generates TypeScript bindings and typed events from the Rust command/event definitions (registered via the Builder in src-tauri/src/lib.rs).

Read the architecture docs before making structural changes — they are the canonical, maintained source; this file only summarizes. See docs/architecture/README.md and:

Doc Contents
01-rust-backend.md Player/session state machines, playback mode, queue, commands
02-svelte-frontend.md Stores, repository architecture, MiniPlayer, autoplay, nav guard
03-data-flow.md Cache-first query flow, playback initiation, mode transfer
04-type-sync-and-threading.md Rust↔TS type sync, the IPC camelCase convention + param table, locking
05-platform-backends.md MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter
06-downloads-and-offline.md Download manager/worker, smart cache, offline commands
07-connectivity.md HTTP retry, ConnectivityMonitor, reachability model
08-database-design.md Tables, relationships, key queries
09-security.md Token storage, secure storage, network security

Release process lives in docs/release-checklist.md and docs/build/build-release.md.

Core principles (from the architecture docs)

  • Playback state is one-directional. The player (ExoPlayer on Android, MPV on Linux, session poller in remote mode) is the authoritative source of state — position, pause, seeking, rate, track changes. The Svelte UI, OS MediaSession/lockscreen, and MPRIS are consumers; they reflect what the player reports and never determine it.
  • Unified player boundary. UI controls playback only through the frontend facade src/lib/player/index.ts (playerController) — never by calling commands.player* directly. Webview HTML5 <video> reports its state back into Rust via src/lib/player/html5Adapter.ts and the player_report_* commands, so the controller stays the single source of truth in both native and HTML5 modes.
  • Reachability from real traffic. Server online/offline is derived from the outcome of actual repository requests (reported to ConnectivityMonitor), not a side-channel poller. The /System/Info/Public probe runs only while offline, as a recovery detector.
  • Poison-tolerant locking. Access shared std::sync state via the MutexSafe/RwLockSafe helpers in utils/lock.rs, which recover a poisoned lock instead of cascading a panic across the player.
  • Graceful backend init. If a native player backend fails to initialize, the app falls back to a no-op backend and emits backend-init-failed rather than crashing.
  • Domain vocabulary lives in Rust. The frontend is presentation-only and must not encode Jellyfin's taxonomy — e.g. the set of item types that defines a category like "Music". Send an opaque scope/enum across the boundary and let the backend expand it. Single-type presentation (itemType: "Movie", "this page shows albums") is fine; a category → set of types mapping in src/ is a leak. bun run check:boundary is the tripwire; the real gate is the spec's layer assignment. The canonical example lives in Rust: SearchScope::item_types() in repository/types.rs expands an opaque scope the frontend sends. See scoped-search-boundary.md for the incident this rule came from — note the tripwire missed that leak for months because the mapping was assigned to a named const rather than written inline at the query, so a green check:boundary is not proof; it flags item-type array literals only, not run-time-built sets or switch/|| taxonomy.

Writing specs

New feature specs go in docs/specs/ — see its README for the index and what is already built. Start from SPEC-TEMPLATE.md — its "Layer assignment" section forces each piece of logic to be placed in the correct layer (Rust = domain, frontend = presentation) with a reason, which is what prevents boundary leaks. Before accepting a spec, run it past SPEC-REVIEW-CHECKLIST.md. Do not frame a spec around "no Rust changes required" — correct layer placement is the goal, not minimal backend churn.

🔴 A spec becomes an architecture doc when it ships

docs/specs/ holds only work that has not shipped. There is no "Implemented" resting state for a spec file: when the last acceptance criterion is met, fold the design into docs/architecture/ and delete the spec in the same commit.

This is not tidying. A directory that mixes promises with descriptions makes both unreliable — you cannot tell from a file whether it describes the build or proposes a change to it, and stale specs then quietly disagree with the code while reading as authority.

  • Every spec names its destination up front — the template's "Destination on completion" line. Deciding at spec time which architecture doc will absorb it is a design check in itself: a feature that fits no existing doc is usually a feature whose layer assignment is unclear.
  • Carry the reasoning, not the plan. The architecture doc gets the why a future change still needs — invariants, rejected alternatives that would be re-attempted, the defect a piece of code exists to prevent. Acceptance criteria, phase breakdowns and migration steps die with the spec; git history keeps them.
  • Deferred work outlives its spec. Anything the spec listed as out-of-scope and still worth doing goes beside the code it concerns, not into the void.
  • Rewrite inbound references before deleting — source comments and CI scripts cite spec paths, and check-doc-links only sees markdown.
  • Partially implemented is a real status. A spec stays until all of it ships, with the header naming what is left.

Conventions

Rust Backend

  • Use #[tauri::command] for all IPC handlers.
  • Prefer async commands for I/O-bound work.
  • Return Result<T, String> from commands (the established convention here).
  • Use tauri::State<> for shared state.
  • Group related commands in domain modules under commands/.
  • Use official Tauri plugins before writing custom native code.

Frontend

  • Use invoke<T>() from @tauri-apps/api/core, or the tauri-specta bindings.
  • Define TS types matching the Rust structs; prefer the generated bindings.
  • Handle IPC errors with try/catch.
  • Use @tauri-apps/api/path for paths (never hardcode).
  • Use @tauri-apps/api/event for backend→frontend events.

🔴 IPC parameter naming (Tauri v2)

The command name must match the Rust function name exactly (invoke("player_play_queue", …)). But parameter names do NOT — Tauri v2's #[tauri::command] macro auto-converts snake_case Rust params to camelCase on the frontend:

#[tauri::command]
pub async fn cmd(repository_handle: String) {  }
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted

Nested struct fields need #[serde(rename_all = "camelCase")]; tagged unions use #[serde(tag = "type")] and both sides must match the tag. Note: tauri-specta tagged responses keep the Rust field names as-is (e.g. new_url, not newUrl).

Events

  • Backend events use kebab-case names (download-event, search-event).
  • Emit from Rust via emit(...); consume on the frontend via @tauri-apps/api/event or the tauri-specta typed event bindings.

Security

  • Declare minimum permissions in src-tauri/capabilities/.
  • Keep the CSP restrictive in tauri.conf.json.
  • Validate all inputs in Rust command handlers.
  • Never read credentials (tokens/keys from keyring, env, or stores) without asking the user first.

Gotchas (hard-won)

  • Never call sync/blocking APIs from event callbacks that can re-enter the player or hold a lock — it deadlocks. On Android, bind a locked AutoplayDecision to a let before matching; a tokio MutexGuard held in the match scrutinee deadlocks the AdvanceToNext arm.
  • VideoPlayer native mode: no lifecycle calls after an await in onMount (it flips to HTML5 mode and breaks Android seek).
  • Transcoded resume/seek: get_video_stream_url must return the HLS master.m3u8, not stream.mp4, or transcoded playback never starts.
  • Downloads cap at 3 concurrent; the backend pump auto-starts pending rows. Don't loop startDownload from the frontend.
  • Parallel Claude sessions: the user may run concurrent sessions. Unexpected file changes may be another session — check git diff before "repairing".

Testing

🔴 Bug fixes: failing test FIRST, then the fix

When fixing a bug, write a test that reproduces it and watch it fail before touching the fix. Red → green, in that order:

  1. Write a test that exercises the broken behavior and run it — it must fail, proving the test actually catches the bug (a test that passes before the fix proves nothing).
  2. Apply the fix.
  3. Re-run — the test now passes, and so does the rest of the suite.

Never fix first and backfill the test afterward: a test written against already-fixed code can pass for the wrong reason and silently fails to guard the regression. If the logic is buried in a component, extract the pure part into a plain .ts module (e.g. episodeStrip.ts) so it can be unit-tested — the same pattern as TrackList.logic.test.ts.

# Rust
cd src-tauri && cargo test
cd src-tauri && cargo test test_name        # single test

# Frontend
bun run test
bun run test:coverage

# Tauri IPC param-naming integration tests (guard the camelCase rule):
bun run test -- tauriIntegration.test.ts