# 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; `MediaKind` enum + isolated > `from_jellyfin` mapping. The model gained real distinctions the flat > `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`. > - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`). > - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`); > `formatDuration` takes 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` → `imageId` rename (naming-only; ~40 sites across catalog + > `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id` > round-trip). Catalog `MediaItem` already has `imageId`. > - 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.ts` once 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](../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](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: 1. **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 `primaryImageTag` means, and that `"Audio"` is a track. That is domain knowledge in the wrong layer, 36 files deep. 2. **Fragility.** `type: string` is 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. 3. **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"` (in `TrackList`, playback context, sessions) are **already domain-neutral** — they are *our* vocabulary, not Jellyfin's. They stay as-is; do not confuse them with `item_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::MediaItem` is *the* model. Rust (repositories, player, downloads) uses it directly. The frontend uses the generated `bindings.ts` projection 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 in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary touches the domain type. Adding a second provider later means a new `from_.rs` beside it — the model and every consumer stay untouched. - **`domain` is a top-level module** (not under `repository/`) because `MediaItem` is used by `player/`, `download/`, and `playback_mode/` too — it is not repository-specific. - The existing `JellyfinItem` DTO + `to_media_item()` in [online.rs](../../src-tauri/src/repository/online.rs) is the seam that already exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real translation instead of copying `item_type` through. ### The domain model (Rust) ```rust // 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, // 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, // was: primary_image_tag pub backdrop_image_ids: Option>, pub overview: Option, pub genres: Option>, pub production_year: Option, pub release_date: Option, // was: premiere_date (ISO-8601) pub community_rating: Option, pub official_rating: Option, // Relationships — already neutral, kept. pub album_id: Option, pub album_name: Option, pub album_artist: Option, pub artists: Option>, pub artist_items: Option>, pub series_id: Option, pub series_name: Option, pub season_id: Option, pub season_name: Option, // Ordinal position — rename off Jellyfin's index vocabulary. pub track_number: Option, // was: index_number pub disc_number: Option, // was: parent_index_number pub user_data: Option, pub media_streams: Option>, pub media_sources: Option>, pub people: Option>, } ``` 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`/`Folder` by `is_folder`, `"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`, `"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new `Other` variant — **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](scoped-search-boundary.md)). Grep for `MediaItem` in event definitions before declaring a phase done. ### Frontend after - `MediaItem`/`MediaKind` come from generated `bindings.ts`. - `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical). - `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no longer cross the boundary; keep only any purely-display seconds↔clock helpers if they exist, which are not Jellyfin-specific). - `primaryImageTag` → `imageId` through the existing image-URL command. - **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`. - 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: 1. **Establish the `domain/` module + enriched mapping, tests — no frontend change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move `JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to `domain::MediaItem` as *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). 2. **Flip the wire shape.** Commands + events emit the new `MediaItem`. Regenerate `bindings.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 check` is the driver. 3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks, `jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`. 4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers; tighten the boundary check to forbid Jellyfin item-type strings in `src/` 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` / `MediaSessionType` shapes, 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`/`.kind` anywhere in `src/` (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 in `src/` outside tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted. - [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`. - [ ] The `From` 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:boundary` pass; `cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated. ## Testing **Rust** (`cargo test`): the `From for MediaItem` mapping is the critical surface — - every known `item_type` → correct `MediaKind` (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 (port `jellyfinFieldMapping.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](../../CLAUDE.md): the domain type + mapping `UR-007, UR-008 | `; the tick/field hoist ``; 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 diff` before 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](../architecture/04-type-sync-and-threading.md)): `#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate `bindings.ts`, never hand-edit. - Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the Layer assignment table above is the load-bearing section.