Four gates that were documented but unenforced, plus the flaky test that made a full-suite run untrustworthy. Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy` before every commit for as long as the rule existed, yet neither ran anywhere in CI — the requirement rested on memory alone. Both now run in build-and-test.yml and build-release.yml. rustfmt and clippy are already baked into the builder image, so nothing is installed at job time. `cargo fmt --all -- --check` is strict immediately (the tree is clean). Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings` would fail on unrelated work, so the step carries a TODO to flip the flag once the backlog clears. A compile error still fails it, so it is not a no-op. Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was 86%, so nearly half the matrix could rot before the gate objected. Ratcheted to 82 with the policy written down — it only ever goes up, and is never lowered to make a red build pass. The same figure lives in MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar, and a test fails if the two drift. Dangling IDs: a TRACES comment could name any well-formed ID and the extractor accepted it silently, so typos and renames that missed a call site passed unnoticed. `bun run traces:validate` cross-checks every traced ID against the table rows in requirements.md and fails with the referencing files listed. It spans UT/IT as well, which the coverage orphan list ignores by design. This currently reports DR-189 and UT-188, which are being defined separately. Flaky offlineCatalog test: the first dynamic import of the service paid ~1s to transform its dependency graph, charged to a test body against vitest's 5s default. Alone it passed; under suite-wide contention it timed out. The import is now warmed at collection time, so no test is timing the compiler — the timeout is deliberately unchanged. The store shim also drops subscribers from module instances discarded by resetModules, which previously leaked across tests.
322 lines
16 KiB
Markdown
322 lines
16 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
|
|
```
|
|
|
|
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` 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
|
|
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](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. 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/). **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
|
|
```
|