Spec: Remove Jellyfin-specific models from the frontend
Implementation status (branch
frontend-domain-model, worktree../JellyTau-domain-model): Catalog surface done. The frontend's item classification and time units no longer speak Jellyfin:
domain/module is the single source of truth;MediaKindenum + isolatedfrom_jellyfinmapping. The model gained real distinctions the flatitem_typehad hidden:LiveChannel/ChannelItem/Channel.- Every catalog
item.type === "..."→item.kind(0 remaining insrc/).- Catalog ticks → milliseconds (
durationMs,playbackPositionMs);formatDurationtakes ms; progress bars are unit-consistent.- User-facing type badge →
kindLabel().- Old Jellyfin-named fields remain dual-carried on the wire so nothing broke.
Deferred (tracked, not done):
primaryImageTag→imageIdrename (naming-only; ~40 sites across catalog +PlayerMediaItem/MergedMediaItem, the latter needing a Rustimage_idround-trip). CatalogMediaItemalready hasimageId.- Player/session/reporting tick math (
Queue,SessionCard,RemoteControls,playbackReporting,playerEvents) — crosses storage/Jellyfin command signatures in ticks; needs those commands to accept ms (phase 4).stream.type(mediaStreams[].type) — Jellyfin stream vocabulary (phase 4).- Delete
playbackUnits.ts/jellyfinFieldMapping.tsonce their last consumers migrate; drop the dual-carried fields once nothing reads them.
Status: Partially implemented (catalog surface); see banner.
Requirements: Architectural (boundary integrity — CLAUDE.md core principles).
Allocate new DRs on acceptance; suggested: DR for the domain MediaItem/MediaKind
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
(see requirements.md). Relates to UR-007, UR-008, UR-034.
UX spec: n/a — zero user-visible behaviour change. This is a pure
architecture/boundary migration.
Supersedes / revises: none. Extends the boundary work started in
scoped-search-boundary.md from taxonomy to the
whole media model.
Summary
The frontend currently consumes Jellyfin's data model directly: MediaItem is a
Jellyfin DTO (runTimeTicks, primaryImageTag, parentIndexNumber, a
stringly-typed type: string carrying Jellyfin's item vocabulary), mirrored via
specta into 36+ frontend files, with 127 item.type === "…" string
comparisons across 23 files and two frontend utility modules
(playbackUnits.ts, jellyfinFieldMapping.ts) doing Jellyfin-specific unit and
field conversion in the presentation layer.
This spec defines a provider-neutral domain model, owned by Rust, that the
Jellyfin repository maps into. The frontend consumes only that model. When done,
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
names — remains in src/.
Motivation
Two concrete problems, one strategic:
- Boundary violation at scale. Per CLAUDE.md, the frontend is
presentation-only and Rust owns the domain. Today the domain model itself is
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
a "tick" is, what
primaryImageTagmeans, and that"Audio"is a track. That is domain knowledge in the wrong layer, 36 files deep. - Fragility.
type: stringis unchecked: a typo ("Epis0de") or a Jellyfin rename fails silently at runtime with no compiler help, across 127 sites. Tick math (* 10_000_000) duplicated frontend-side is a class of bug the backend should have already resolved. - Strategic (the reason we chose the ambitious target): a neutral domain model is the precondition for ever supporting a non-Jellyfin backend (Plex, local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded shut.
Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
Definition of the media domain model (MediaItem, MediaKind) | Rust | The canonical shape the whole app reasons about; must not be a provider's wire format. |
Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, "Audio"→Track, PremiereDate→releaseDate) | Rust, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
Tick arithmetic (playbackUnits.ts) | Rust | A Jellyfin unit. The frontend should never see ticks; it receives durationMs/positionMs. |
Sort-field mapping (jellyfinFieldMapping.ts, title→SortName) | Rust | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral SortKey. |
MediaKind classification (is this a track / album / episode?) | Rust | Derived from Jellyfin's item_type; the frontend receives the already-classified kind. |
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | Frontend | Pure presentation over the neutral kind. Changes only if the UI is redesigned. |
Navigation decisions (kind === Track && albumId → go to album) | Frontend | Presentation/routing over neutral fields. |
Borderline calls, resolved:
MergedMediaItem(the lightweight now-playing projection) is already half-neutral (title,artist,duration) — it becomes a straightforward subset of the new domain model, not a special case.- Context discriminators
"album","playlist","remote"(inTrackList, playback context, sessions) are already domain-neutral — they are our vocabulary, not Jellyfin's. They stay as-is; do not confuse them withitem_type. Only the Jellyfin item-type strings move. mediaStreams[].type === "Audio"/"Subtitle"/"Video"(track selection in VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and self-contained — deferred to a late phase, not phase 1.
Design
Single canonical model, one location, isolated mappings
The domain model is defined once, in a dedicated top-level Rust module
src-tauri/src/domain/, and is the single source of truth shared across the
whole app:
src-tauri/src/domain/
media.rs canonical MediaItem, MediaKind, and the other media types
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
mod.rs re-exports
| tauri-specta (export_typescript_bindings test)
v
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
- One definition.
domain::MediaItemis the model. Rust (repositories, player, downloads) uses it directly. The frontend uses the generatedbindings.tsprojection of it. There is no second hand-written copy in either language, so it cannot drift — "shared between frontend and backend" is realized by generation, not duplication. - Mappings live beside the model, never in consumers. All provider translation
(
JellyfinItem→domain::MediaItem, ticks→ms, image-tag→id, item-type→MediaKind) lives indomain/from_jellyfin.rs. It is the only place Jellyfin vocabulary touches the domain type. Adding a second provider later means a newfrom_<provider>.rsbeside it — the model and every consumer stay untouched. domainis a top-level module (not underrepository/) becauseMediaItemis used byplayer/,download/, andplayback_mode/too — it is not repository-specific.- The existing
JellyfinItemDTO +to_media_item()in online.rs is the seam that already exists; it moves intodomain/from_jellyfin.rsand is enriched to do real translation instead of copyingitem_typethrough.
The domain model (Rust)
#![allow(unused)] fn main() { // src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary. #[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum MediaKind { Track, Album, Artist, Playlist, // music Movie, Series, Season, Episode, // video Person, // cast/crew Channel, Folder, // containers/live } #[derive(specta::Type, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct MediaItem { pub id: String, pub name: String, pub kind: MediaKind, // was: type: String pub is_folder: bool, pub server_id: String, // Times in milliseconds — NEVER ticks. pub duration_ms: Option<i64>, // was: run_time_ticks // Image as a resolved identifier the frontend turns into a URL via the // existing image command — no raw Jellyfin tag semantics leak. pub image_id: Option<String>, // was: primary_image_tag pub backdrop_image_ids: Option<Vec<String>>, pub overview: Option<String>, pub genres: Option<Vec<String>>, pub production_year: Option<i32>, pub release_date: Option<String>, // was: premiere_date (ISO-8601) pub community_rating: Option<f64>, pub official_rating: Option<String>, // Relationships — already neutral, kept. pub album_id: Option<String>, pub album_name: Option<String>, pub album_artist: Option<String>, pub artists: Option<Vec<String>>, pub artist_items: Option<Vec<ArtistItem>>, pub series_id: Option<String>, pub series_name: Option<String>, pub season_id: Option<String>, pub season_name: Option<String>, // Ordinal position — rename off Jellyfin's index vocabulary. pub track_number: Option<i32>, // was: index_number pub disc_number: Option<i32>, // was: parent_index_number pub user_data: Option<UserData>, pub media_streams: Option<Vec<MediaStream>>, pub media_sources: Option<Vec<MediaSource>>, pub people: Option<Vec<Person>>, } }
The existing JellyfinItem DTO (already defined in online.rs, deserialized
from the Jellyfin JSON) moves into domain/from_jellyfin.rs and stays
private to that module. Its to_media_item() — today a near-passthrough that
copies item_type straight across — is enriched into the single, tested place
that:
- classifies
item_type: String→MediaKind(including the edge cases found in the audit:"ChannelFolderItem"→Channel/Folderbyis_folder,"TvChannel"→Channel,"Composer"/"Director"/"Writer"→Person,"Video"→Movieor a video leaf). Unknown strings map toFolderor a newOthervariant — decide at implementation; must not panic. - converts
run_time_ticks→duration_ms(ticks / 10_000). - maps
PremiereDate→release_date, image tags → image ids.
SortKey enum + its Jellyfin field mapping (jellyfinFieldMapping.ts contents)
moves into the Jellyfin repository; the command takes a neutral SortKey.
🔴 The search-event / dual-payload rule applies again
Every path that returns MediaItem — command returns and the search-event
and any other event payloads — emits the new domain shape. Both sides of a
twice-delivered result must match (same rule as
scoped-search-boundary.md). Grep for MediaItem in
event definitions before declaring a phase done.
Frontend after
MediaItem/MediaKindcome from generatedbindings.ts.item.type === "Audio"→item.kind === "track"(127 sites, mechanical).runTimeTicksusages →durationMs; deleteplaybackUnits.ts(ticks no longer cross the boundary; keep only any purely-display seconds↔clock helpers if they exist, which are not Jellyfin-specific).primaryImageTag→imageIdthrough the existing image-URL command.- Delete
jellyfinFieldMapping.ts; sort options send a neutralSortKey. - Assert with the boundary tripwire + a new grep (see acceptance).
Phased migration
This is too large and too collision-prone for one change. Phases are independently shippable, each keeps all tests green, and each is a reviewable PR:
- Establish the
domain/module + enriched mapping, tests — no frontend change yet. Createsrc-tauri/src/domain/{media,from_jellyfin,mod}.rs. MoveJellyfinItem/to_media_itemin. AddMediaKindand the neutral fields todomain::MediaItemas additive, defaulted fields, and populate them in the mapping, while keeping the old Jellyfin-named fields too (dual-carry). The wire shape is a superset of today's, so the frontend still compiles and behaves identically. Lands the authority + full mapping unit coverage first, with zero blast radius on the 52 construction sites (they set the old fields; new ones default). - Flip the wire shape. Commands + events emit the new
MediaItem. Regeneratebindings.ts. Frontend breaks to compile errors — fix them mechanically (type→kind, values"Audio"→"track",runTimeTicks→durationMs,primaryImageTag→imageId). This is the big mechanical PR;bun run checkis the driver. - Delete the frontend conversion helpers (
playbackUnits.tsticks,jellyfinFieldMapping.ts) and route sorting through the neutralSortKey. - Stream vocabulary (
mediaStreams[].type) and any remaining stragglers; tighten the boundary check to forbid Jellyfin item-type strings insrc/outside tests.
Ship 1 → 2 → 3 → 4 as separate PRs. Do not attempt all four at once.
Out of scope
- Actually adding a second backend (Plex/Subsonic). This spec only unblocks it.
- Changing any user-visible behaviour, layout, or copy.
- The player-internal
PlayerMediaItem/MediaSessionTypeshapes, except where they carry the fields being renamed — align them in phase 2 only if the compiler demands it. - Context discriminators (
"album","playlist","remote") — already neutral.
Acceptance criteria
-
No Jellyfin item-type string (
"Audio","MusicAlbum","Series", …) is compared against.type/.kindanywhere insrc/(outside tests). Verify:grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/returns nothing. -
No
Ticks,runTimeTicks,primaryImageTag,PremiereDate, or Jellyfin sort-field name (SortName,RunTimeTicks, …) appears insrc/outside tests.playbackUnits.ts(ticks) andjellyfinFieldMapping.tsare deleted. -
MediaItem/MediaKind/SortKeyin the frontend come frombindings.ts. -
The
From<JellyfinMediaDto>mapping is total and never panics on an unknown item type (Rust test with a garbage type string). - Behaviour is identical: same library/search/home rendering, same sorting, same navigation, offline included.
- Both command returns and event payloads carry the new shape (no flicker).
-
bun run check,bun run test,bun run check:boundarypass;cargo fmt/cargo clippy/bun run test:rustpass;bindings.tsregenerated.
Testing
Rust (cargo test): the From<JellyfinMediaDto> for MediaItem mapping is the
critical surface —
- every known
item_type→ correctMediaKind(table test over all 20 values found in the audit, incl.ChannelFolderItem,TvChannel,Composer); - unknown type string → safe fallback, no panic;
run_time_ticks→duration_ms(10_000 divisor), boundary/None cases;SortKey→ Jellyfin field mapping (portjellyfinFieldMapping.ts's cases).
Frontend (vitest): update the many tests asserting .type/runTimeTicks;
they become .kind/durationMs. jellyfinFieldMapping/playbackUnits tests are
deleted with their modules. Add a compose/render test proving kind-based
branching matches the old type-based branching for a representative mix.
TRACES
Per CLAUDE.md: the domain type + mapping
UR-007, UR-008 | <new DR>; the tick/field hoist <new DR>; frontend migration
phases share the DRs of the capability each touches (don't invent per-file DRs).
Notes for the implementer
- This is the highest-collision change in the repo's history — it touches 36+
frontend files and the core Rust types. A parallel Claude session in any media
file will conflict. Strongly prefer a dedicated worktree per phase, and
git diffbefore repairing anything (CLAUDE.md gotchas / project memory). - Phase 1 deliberately maps back to the old shape so it can land safely ahead of the disruptive flip. Resist the urge to skip it.
- IPC camelCase rules apply to the new enums/structs
(04-type-sync-and-threading.md):
#[serde(rename_all = "camelCase")]; tagged-enum tag convention; regeneratebindings.ts, never hand-edit. - Reviewed against SPEC-REVIEW-CHECKLIST.md — the Layer assignment table above is the load-bearing section.