R8 has broken release APKs here before by stripping the JNI-loaded player and security classes, and the only way to reproduce that was to build with the real signing key and clobber the install you actually use. `./scripts/build-and-deploy.sh release --device --debug` now builds a fully minified release APK — exactly what ships — into the .debug applicationId slot, signed with the local debug keystore: release com.dtourolle.jellytau 0.5.5 release --debug com.dtourolle.jellytau.debug 0.5.5-debug-release debug com.dtourolle.jellytau.debug 0.5.5-debug It shares the applicationId *and* the signature with the plain debug build, so the two replace each other cleanly rather than colliding, and the versionName suffix says which is currently installed. No real key is needed, so the side-by-side path deliberately skips write-keystore-properties.sh. The flag reaches Gradle as JT_SIDE_BY_SIDE=1. CI never sets it, and the release manifest merges byte-identical without it — verified both ways through processUniversalReleaseMainManifest. deploy-android.sh and build-and-deploy.sh learned the flag too, since the APK path is unchanged but the package to launch is not.
15 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
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 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-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