CLAUDE.md now states the failing-test-first rule explicitly: write a test that reproduces the bug and watch it fail before applying the fix, and extract buried logic into a plain .ts module so it can be unit-tested. A test written against already-fixed code can pass for the wrong reason.
298 lines
14 KiB
Markdown
298 lines
14 KiB
Markdown
# 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/`:
|
|
|
|
```bash
|
|
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`.
|
|
|
|
> **🔴 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,
|
|
> 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` 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.:
|
|
|
|
```rust
|
|
/// TRACES: UR-005 | DR-001
|
|
pub enum PlayerState { … }
|
|
```
|
|
```typescript
|
|
// 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](docs/requirements.md); the generated matrix is
|
|
[docs/traceability.md](docs/traceability.md).
|
|
|
|
Tooling:
|
|
|
|
```bash
|
|
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](docs/traceability-ci.md) and
|
|
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
|
|
|
### Traces drive release notes
|
|
|
|
Prefer traceability over raw commit subjects when writing release notes for
|
|
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
|
|
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
|
|
release touched.
|
|
|
|
```bash
|
|
bun run release:notes # <latest tag>..HEAD
|
|
bun run release:notes v0.0.15..HEAD # explicit range
|
|
```
|
|
|
|
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
|
|
changed files → their `TRACES:` IDs → descriptions in
|
|
[docs/requirements.md](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](docs/architecture/README.md) and:
|
|
|
|
| Doc | Contents |
|
|
|-----|----------|
|
|
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
|
|
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
|
|
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
|
|
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
|
|
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
|
|
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
|
|
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
|
|
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
|
|
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
|
|
|
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
|
and [docs/build-release.md](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](docs/specs/scoped-search-boundary.md)
|
|
for the incident this rule came from.
|
|
|
|
## Writing specs
|
|
|
|
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
|
[SPEC-TEMPLATE.md](docs/specs/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](docs/specs/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:
|
|
|
|
```rust
|
|
#[tauri::command]
|
|
pub async fn cmd(repository_handle: String) { … }
|
|
```
|
|
```typescript
|
|
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`.
|
|
|
|
```bash
|
|
# 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
|
|
```
|