Files
jellytau/CLAUDE.md
T
dtourolle 2cc39cd7fd build(android): install the debug build alongside release as its own app
Testing a debug build meant uninstalling the real one first: same
applicationId signed with a different key is INSTALL_FAILED_UPDATE_
INCOMPATIBLE, so every experiment cost the app's settings, credentials
and offline cache.

The debug build type now carries applicationIdSuffix ".debug" and
versionNameSuffix "-debug", so it installs as com.dtourolle.jellytau.debug
("JellyTau Debug", 0.5.5-debug) with its own data directory — two
independent apps on one device.

Only the *application* id is suffixed. Kotlin classes stay in the
`namespace` package com.dtourolle.jellytau, so the JNI loadClass lookups
in player/android/mod.rs, the manifest <service> entry and the R8 keep
rules are untouched, and the FileProvider authority was already
${applicationId}-relative. Launcher names come from the appLabel /
activityLabel manifestPlaceholders rather than resValue, which would
collide with Tauri's generated strings.xml; release resolves them back to
@string/app_name and merges byte-identical.

deploy-android.sh reports the target package and explains an
UPDATE_INCOMPATIBLE failure instead of leaving it raw; logcat.sh takes a
debug|release argument (it was filtering on com.jellytau.app, a package
that has never existed) and attaches by pid when the app is running.

Verified: aapt2 badging on the built APK reports
com.dtourolle.jellytau.debug / 0.5.5-debug / "JellyTau Debug", and the
release manifest merge is unchanged.
2026-08-16 10:36:48 +02:00

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. 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.buildergitea.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.:

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

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

# 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