Updates the two referrers outside the docs tree that the move left behind, and excludes .claude/ from the link checker — the agent worktrees under it are full checkouts, so it was walking every in-flight branch and reporting their links as ours.
16 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 test:e2e # webdriverio e2e
# 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.builder→gitea.tourolle.paris/dtourolle/jellytau-builder) for Android/Linux/Windows, orDockerfile.archfor 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 thinFROM ${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 checkandbun run testmust pass. - Rust:
cd src-tauri && cargo fmtthencargo clippy, plusbun run test:rust. - Boundary:
bun run check:boundarymust 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 runscripts/sync-android-sources.shto sync into thegen/tree. Never edit the generatedgen/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.md — traces: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
82% (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 + Rust tests, cargo fmt --check, an advisory cargo clippy, 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 insrc-tauri/src/commands/(auth.rs,catalog.rs,player/,download/,offline.rs,sessions.rs, …). - Svelte frontend (
src/) — presentation only. Stores insrc/lib/stores/, API wrappers insrc/lib/api/, components insrc/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 callingcommands.player*directly. Webview HTML5<video>reports its state back into Rust viasrc/lib/player/html5Adapter.tsand theplayer_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/Publicprobe runs only while offline, as a recovery detector. - Poison-tolerant locking. Access shared
std::syncstate via theMutexSafe/RwLockSafehelpers inutils/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-failedrather 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 insrc/is a leak.bun run check:boundaryis the tripwire; the real gate is the spec's layer assignment. The canonical example lives in Rust:SearchScope::item_types()inrepository/types.rsexpands 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 greencheck:boundaryis not proof; it flags item-type array literals only, not run-time-built sets orswitch/||taxonomy.
Writing specs
New feature specs go in docs/specs/. 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.
Conventions
Rust Backend
- Use
#[tauri::command]for all IPC handlers. - Prefer
asynccommands 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/pathfor paths (never hardcode). - Use
@tauri-apps/api/eventfor 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/eventor 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
AutoplayDecisionto aletbefore matching; a tokioMutexGuardheld in thematchscrutinee deadlocks theAdvanceToNextarm. - VideoPlayer native mode: no lifecycle calls after an
awaitinonMount(it flips to HTML5 mode and breaks Android seek). - Transcoded resume/seek:
get_video_stream_urlmust return the HLSmaster.m3u8, notstream.mp4, or transcoded playback never starts. - Downloads cap at 3 concurrent; the backend pump auto-starts pending rows.
Don't loop
startDownloadfrom the frontend. - Parallel Claude sessions: the user may run concurrent sessions. Unexpected
file changes may be another session — check
git diffbefore "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:
- 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).
- Apply the fix.
- 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