Files
jellytau/CLAUDE.md
T
dtourolle 1677f5f299 refactor(player): delete the webview video path; mpv selects its own tracks
DR-235 phase 3. Every video renderer is native now: mpv on Linux and
Windows, ExoPlayer on Android, all drawing behind the transparent
webview. The HTML5 <video> path is gone, not bypassed:

- Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the
  createAdapter factory, streamTransport, hlsRecovery, timeTracking,
  videoFit, the <video>/<track> markup and every element handler in
  VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store
  and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and
  the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one
  video adapter; webview audio gets its own adapter kind.
- Rust: use_html5 dropped from player_seek_video,
  player_switch_audio_track and player_set_stream_quality with the
  Html5* strategies and ReloadStream responses; use_html5_element and
  VideoBackend dropped from PlayerStatus; player_play_item always loads
  the backend (set_current_item removed); Capabilities::webview removed;
  the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed.
- Android: the HTML5 video state in PictureInPictureManager and
  ScreenWakeManager, and the bridge method feeding it.
- CSP: connect-src loses http:/https: and worker-src loses blob: -
  both existed for hls.js; with it gone they were only an exfiltration
  channel and a blob worker for injected script. A test now keeps them
  out.

mpv takes over what the <video> element did (mpv_tracks, UT-275):
subtitles are the WebVTT list the play request carries, queued on
sub-files and selected by position in that list, starting off; audio
tracks are selected by position in the file; sid/aid are reset before
each load. Without this, Linux video had no subtitle selection and a
direct-play audio switch failed since mpv became its renderer.

Verified: Rust 948 passing, and the same 948 cross-compiled for Windows
under wine against the shipped DLL (track tests included); frontend
1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI
ratchet tightened to match. Not yet seen on Windows hardware.
2026-09-24 23:11:17 -04:00

18 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 and Windows (libmpv for audio and video) and Android (ExoPlayer). There is no webview <video>: every video renderer is native, drawing behind the transparent webview.

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

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

/// 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
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 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 and Windows use libmpv for audio and video (mpv draws video beneath the transparent webview: the render API into a GTK surface on Linux, wid into the app window on Windows); Android uses ExoPlayer with a foreground media service + MediaSessionCompat. The webview <video>/hls.js path was deleted (DR-235). Every mpv command goes through player/mpv_command.rs (argv, never a command string) and every handle is hardened (tls-verify=yes, ytdl=no) — see DR-298/DR-299.
  • 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/Windows) incl. desktop native video, ExoPlayerBackend (Android), MediaSession
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/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. Video has one adapter, NativePlayerAdapter, which only forwards intents; the backend performs every seek, track switch and quality change itself.
  • 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/ — see its README for the index and what is already built. 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.

🔴 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/ 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:

#[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 onMount: no lifecycle calls after an await — they throw lifecycle_outside_component (this once silently switched seeks to a renderer that was not playing).
  • 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