Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
JellyTau Software Architecture
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
Last Updated: 2026-06-20
Architecture Overview
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
Architecture Principles
- Business Logic in Rust: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
- Presentation in Svelte: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, not a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k);
VideoPlayer.sveltealone is ~1.6k lines. - Events + Polling hybrid: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in
library/+layout.svelte, playback progress inVideoPlayer.svelte). - Unified player boundary: UI components control playback only through the frontend facade
src/lib/player/index.ts(playerController), never by callingcommands.player*directly. Webview-rendered HTML5 video reports its state back into Rust viasrc/lib/player/html5Adapter.tsand theplayer_report_*commands, so thePlayerControllerstays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see 05-platform-backends.md). - Handle-Based Resources: UUID handles for stateful Rust objects.
- Cache-First: Parallel queries with intelligent fallback.
- Single source of truth for reachability: Server reachability is derived from the outcome of real repository traffic, not a side-channel poller. The
OnlineRepositoryreports each server result to theConnectivityMonitor(classified viaRepoError), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone/System/Info/Publicprobe runs only while offline, as a recovery detector for idle sessions. - Poison-tolerant locking: Shared
std::syncstate is accessed 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 (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a
backend-init-failedevent rather than crashing.
flowchart TB
subgraph Frontend["Svelte Frontend"]
subgraph Stores["Stores (Thin Wrappers)"]
auth["auth"]
player["player"]
queue["queue"]
library["library"]
connectivity["connectivity"]
playbackMode["playbackMode"]
end
subgraph Components
playerComp["player/"]
libraryComp["library/"]
Search["Search"]
end
subgraph Routes
routeLibrary["/library"]
routePlayer["/player"]
routeRoot["/"]
end
subgraph API["API Layer (Thin Client)"]
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
JellyfinClient["JellyfinClient<br/>(Helper)"]
end
end
Frontend -->|"Tauri IPC (invoke)"| Backend
subgraph Backend["Rust Backend (Business Logic)"]
subgraph Commands["Tauri Commands (90+)"]
PlayerCmds["player.rs"]
RepoCmds["repository.rs (27)"]
PlaybackModeCmds["playback_mode.rs (5)"]
StorageCmds["storage.rs"]
ConnectivityCmds["connectivity.rs (7)"]
end
subgraph Core["Core Modules"]
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
end
subgraph Storage["Storage Layer"]
DatabaseService["DatabaseService<br/>(Async trait)"]
SQLite["SQLite Database<br/>(13 tables)"]
end
Commands --> Core
Core --> Storage
Repository --> HttpClient
Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end
The
Repository --> ConnectivityMonitoredge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see 07-connectivity.md).
Detailed Documentation
Each major subsystem is documented in its own file in this directory:
| Document | Contents |
|---|---|
| 01 - Rust Backend | Media session state machine, player state machine, playback mode, media items, queue manager, favorites (marking + browsing), player backend trait, player controller, playlist system, domain vocabulary owned by Rust (search scope, library exclusions, streaming quality ladder), background workers (catalog indexer, drains), Tauri commands |
| 02 - Svelte Frontend | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
| 03 - Data Flow | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
| 04 - Type Sync & Threading | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| 05 - Platform Backends | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, native video compositing, MediaSession & remote volume, album art caching, backend initialization |
| 06 - Downloads & Offline | Download manager, download worker, smart caching engine, one storage model (cache entries are downloads), offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
| 07 - Connectivity | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| 08 - Database Design | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
| 09 - Security | Authentication token storage, secure storage module, network security, webview CSP + asset-protocol scope, path confinement and input binding, local data protection |
File Structure Summary
src-tauri/src/
├── lib.rs # Tauri app setup, state initialization
├── commands/ # Tauri command handlers (~245 #[tauri::command] fns)
│ ├── mod.rs # Command exports
│ ├── player/ # Player commands: queue, remote, session, settings, timers
│ ├── repository.rs # Repository commands (items, search, favourites, disk usage)
│ ├── catalog.rs # Catalog sync + the background index pass
│ ├── favorites.rs # Offline favourite drain
│ ├── library.rs # Library listing + folder exclusions
│ ├── playlist.rs # Playlist commands
│ ├── playback_mode.rs # Local/remote transfer
│ ├── playback_reporting.rs
│ ├── connectivity.rs # Connectivity commands
│ ├── storage/ # Storage & database commands: people, series_prefs, thumbnails
│ ├── download/ # Download commands: mod, pinning, smart_cache
│ ├── offline.rs # Offline commands
│ ├── device.rs # Device id / capabilities
│ ├── sessions.rs # Remote sessions
│ ├── sync.rs # Sync queue commands
│ └── sync_drain.rs # Background sync-queue drain
├── repository/ # Repository pattern implementation
│ ├── mod.rs # MediaRepository trait, handle management
│ ├── types.rs # RepoError, Library, MediaItem, etc.
│ ├── hybrid.rs # HybridRepository with cache-first racing
│ ├── online.rs # OnlineRepository (HTTP API)
│ └── offline.rs # OfflineRepository (SQLite queries)
├── playback_mode/ # Playback mode manager
│ └── mod.rs # PlaybackMode enum, transfer logic
├── connectivity/ # Connectivity monitoring
│ └── mod.rs # ConnectivityMonitor, adaptive polling
├── jellyfin/ # Jellyfin API client
│ ├── mod.rs # Module exports
│ ├── http_client.rs # HTTP client with retry logic
│ └── client.rs # JellyfinClient for API calls
├── storage/ # Database layer
│ ├── mod.rs # Database struct, migrations
│ ├── db_service.rs # DatabaseService trait (async wrapper)
│ ├── schema.rs # Table definitions
│ └── queries/ # Query modules
├── download/ # Download manager module
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
│ ├── events.rs # DownloadEvent enum
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
└── player/ # Player subsystem
├── mod.rs # PlayerController
├── session.rs # MediaSessionManager, MediaSessionType
├── state.rs # PlayerState, PlayerEvent
├── media.rs # MediaItem, MediaSource, MediaType
├── queue.rs # QueueManager, RepeatMode
├── backend.rs # PlayerBackend trait, NullBackend
├── events.rs # PlayerStatusEvent, TauriEventEmitter
├── mpv/ # Linux MPV backend
│ ├── mod.rs # MpvBackend implementation
│ └── event_loop.rs # Dedicated thread for MPV operations
└── android/ # Android ExoPlayer backend
└── mod.rs # ExoPlayerBackend + JNI bindings
src/lib/
├── api/ # Thin API layer (~200 lines total)
│ ├── types.ts # TypeScript type definitions
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
│ ├── client.ts # JellyfinClient (helper for streaming)
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
├── stores/ # Thin reactive wrappers over Rust commands
│ ├── index.ts # Re-exports
│ ├── auth.ts # Auth store (calls Rust commands)
│ ├── player.ts # Player store
│ ├── queue.ts # Queue store
│ ├── library.ts # Library store
│ ├── playbackMode.ts # Playback mode store (~150 lines)
│ ├── connectivity.ts # Connectivity store (~250 lines)
│ └── downloads.ts # Downloads store with event listeners
└── components/
├── Search.svelte
├── player/ # Player UI components
├── playlist/ # Playlist modals (Create, AddTo)
├── sessions/ # Remote session control UI
├── downloads/ # Download UI components
└── library/ # Library UI components + PlaylistDetailView
Key Architecture Changes
What moved to Rust (~3,500 lines of business logic):
- HTTP Client (338 lines) - Retry logic with exponential backoff
- Connectivity Monitor (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
- Repository Pattern (1061 lines) - Cache-first hybrid with parallel racing
- Database Service - Async wrapper preventing UI freezing
- Playback Mode (303 lines) - Local/remote transfer coordination
Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):
- Components + routes (~14.6k lines) — UI and presentation
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
Total Commands: ~245 #[tauri::command] functions across 17 command modules
(~58k lines of Rust, ~37k non-test lines of TypeScript/Svelte).
Counts and line totals in this file are periodic snapshots, not gates — the authority is the tree. Regenerate with
grep -rc '#\[tauri::command\]' src-tauri/srcandwc -l.