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.
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, 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 check,bun run test,bun run format:checkandbun run lint(0 errors; the warning count is a CI ratchet) must 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
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 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 and Windows use libmpv for audio and video (mpv
draws video beneath the transparent webview: the render API into a GTK surface
on Linux,
widinto 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 throughplayer/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 callingcommands.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/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/ — 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-linksonly 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
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
onMount: no lifecycle calls after anawait— they throwlifecycle_outside_component(this once silently switched seeks to a renderer that was not playing). - 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