# 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 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](src-tauri/android/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,
> 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.:

```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
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
**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](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/build-release.md](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](docs/specs/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/](docs/specs/) — see its
[README](docs/specs/README.md) for the index and what is already built.
**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.

### 🔴 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/](docs/architecture/README.md) 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:

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