Files
jellytau/CLAUDE.md
T
dtourolle f89b241ad6 docs: document frontend/backend boundary rule and spec workflow
Add the "domain vocabulary lives in Rust" principle, the
check:boundary pre-commit gate, and a spec-writing section pointing at
the spec template and review checklist.
2026-07-23 20:04:54 +02:00

12 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

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.

Before Committing

  • Frontend: bun run check and bun run test 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
git diff --name-only | xargs grep -L "TRACES:"   # find untraced changed files

CI is Gitea Actions (.gitea/workflows/, remote gitea.tourolle.paris), not GitHub. traceability-check.yml fails the build if coverage drops below 50% (MIN_THRESHOLD); build-and-test.yml runs frontend + Rust tests 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-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. See scoped-search-boundary.md for the incident this rule came from.

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 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

# 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