Files
jellytau/specs/frontend-domain-model.html
T

535 lines
39 KiB
HTML

<!DOCTYPE HTML>
<html lang="en" class="navy" dir="ltr">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>Frontend Domain Model - JellyTau Documentation</title>
<!-- Custom HTML head -->
<meta name="description" content="Requirements, traceability, and architecture for the JellyTau Jellyfin client.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="../favicon.svg">
<link rel="shortcut icon" href="../favicon.png">
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/general.css">
<link rel="stylesheet" href="../css/chrome.css">
<link rel="stylesheet" href="../css/print.css" media="print">
<!-- Fonts -->
<link rel="stylesheet" href="../FontAwesome/css/font-awesome.css">
<link rel="stylesheet" href="../fonts/fonts.css">
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" href="../highlight.css">
<link rel="stylesheet" href="../tomorrow-night.css">
<link rel="stylesheet" href="../ayu-highlight.css">
<!-- Custom theme stylesheets -->
</head>
<body class="sidebar-visible no-js">
<div id="body-container">
<!-- Provide site root to javascript -->
<script>
var path_to_root = "../";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "navy" : "navy";
</script>
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
var theme = localStorage.getItem('mdbook-theme');
var sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
var html = document.querySelector('html');
html.classList.remove('navy')
html.classList.add(theme);
var body = document.querySelector('body');
body.classList.remove('no-js')
body.classList.add('js');
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
var body = document.querySelector('body');
var sidebar = null;
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
body.classList.remove('sidebar-visible');
body.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<div class="sidebar-scrollbox">
<ol class="chapter"><li class="chapter-item expanded affix "><a href="../index.html">Introduction</a></li><li class="chapter-item expanded affix "><li class="part-title">Requirements & Traceability</li><li class="chapter-item expanded "><a href="../requirements.html"><strong aria-hidden="true">1.</strong> Requirements Specification</a></li><li class="chapter-item expanded "><a href="../traceability.html"><strong aria-hidden="true">2.</strong> Traceability Matrix</a></li><li class="chapter-item expanded "><a href="../traceability-ci.html"><strong aria-hidden="true">3.</strong> Traceability CI</a></li><li class="chapter-item expanded "><a href="../traces-quick-ref.html"><strong aria-hidden="true">4.</strong> Traces Quick Reference</a></li><li class="chapter-item expanded affix "><li class="part-title">Architecture</li><li class="chapter-item expanded "><a href="../architecture/index.html"><strong aria-hidden="true">5.</strong> Overview</a><a class="toggle"><div></div></a></li><li><ol class="section"><li class="chapter-item "><a href="../architecture/01-rust-backend.html"><strong aria-hidden="true">5.1.</strong> Rust Backend</a></li><li class="chapter-item "><a href="../architecture/02-svelte-frontend.html"><strong aria-hidden="true">5.2.</strong> Svelte Frontend</a></li><li class="chapter-item "><a href="../architecture/03-data-flow.html"><strong aria-hidden="true">5.3.</strong> Data Flow</a></li><li class="chapter-item "><a href="../architecture/04-type-sync-and-threading.html"><strong aria-hidden="true">5.4.</strong> Type Sync & Threading</a></li><li class="chapter-item "><a href="../architecture/05-platform-backends.html"><strong aria-hidden="true">5.5.</strong> Platform Backends</a></li><li class="chapter-item "><a href="../architecture/06-downloads-and-offline.html"><strong aria-hidden="true">5.6.</strong> Downloads & Offline</a></li><li class="chapter-item "><a href="../architecture/07-connectivity.html"><strong aria-hidden="true">5.7.</strong> Connectivity</a></li><li class="chapter-item "><a href="../architecture/08-database-design.html"><strong aria-hidden="true">5.8.</strong> Database Design</a></li><li class="chapter-item "><a href="../architecture/09-security.html"><strong aria-hidden="true">5.9.</strong> Security</a></li></ol></li><li class="chapter-item expanded "><li class="part-title">UX</li><li class="chapter-item expanded "><a href="../ux-flows.html"><strong aria-hidden="true">6.</strong> UX Flows</a></li><li class="chapter-item expanded affix "><li class="part-title">Specs — Pending Work</li><li class="chapter-item expanded "><a href="../specs/index.html"><strong aria-hidden="true">7.</strong> Specs Index</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-TEMPLATE.html"><strong aria-hidden="true">8.</strong> Spec Template</a></li><li class="chapter-item expanded "><a href="../specs/SPEC-REVIEW-CHECKLIST.html"><strong aria-hidden="true">9.</strong> Spec Review Checklist</a></li><li class="chapter-item expanded "><a href="../specs/playback-backend-unification.html"><strong aria-hidden="true">10.</strong> Playback Backend Unification</a></li><li class="chapter-item expanded "><a href="../specs/linux-native-video-spike.html"><strong aria-hidden="true">11.</strong> Linux Native Video Spike</a></li><li class="chapter-item expanded "><a href="../specs/backend-owned-stream-selection.html"><strong aria-hidden="true">12.</strong> Backend-Owned Stream Selection</a></li><li class="chapter-item expanded "><a href="../specs/player-facade-enforcement.html"><strong aria-hidden="true">13.</strong> Player Facade Enforcement</a></li><li class="chapter-item expanded "><a href="../specs/windows-native-audio-backend.html"><strong aria-hidden="true">14.</strong> Windows Native Audio Backend</a></li><li class="chapter-item expanded "><a href="../specs/libmpv2-migration.html"><strong aria-hidden="true">15.</strong> libmpv2 Migration</a></li><li class="chapter-item expanded "><a href="../specs/read-through-media-cache.html"><strong aria-hidden="true">16.</strong> Read-Through Media Cache</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search.html"><strong aria-hidden="true">17.</strong> Scoped Search</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary.html"><strong aria-hidden="true">18.</strong> Scoped Search Boundary</a></li><li class="chapter-item expanded "><a href="../specs/scoped-search-boundary-implementation.html"><strong aria-hidden="true">19.</strong> Scoped Search Boundary — Implementation</a></li><li class="chapter-item expanded "><a href="../specs/frontend-domain-model.html" class="active"><strong aria-hidden="true">20.</strong> Frontend Domain Model</a></li><li class="chapter-item expanded "><a href="../specs/build-provenance.html"><strong aria-hidden="true">21.</strong> Build Provenance</a></li><li class="chapter-item expanded affix "><li class="part-title">Build & Release</li><li class="chapter-item expanded "><a href="../build/build-release.html"><strong aria-hidden="true">22.</strong> Build & Release</a></li><li class="chapter-item expanded "><a href="../release-checklist.html"><strong aria-hidden="true">23.</strong> Release Checklist</a></li><li class="chapter-item expanded "><a href="../build/build-desktop-packages.html"><strong aria-hidden="true">24.</strong> Desktop Packaging</a></li><li class="chapter-item expanded "><a href="../build/build-windows.html"><strong aria-hidden="true">25.</strong> Windows Build</a></li><li class="chapter-item expanded "><a href="../defect-windows.html"><strong aria-hidden="true">26.</strong> Defect Windows</a></li><li class="chapter-item expanded "><a href="../build/docker.html"><strong aria-hidden="true">27.</strong> Docker</a></li><li class="chapter-item expanded "><a href="../build/build-builder-image.html"><strong aria-hidden="true">28.</strong> Builder Image</a></li><li class="chapter-item expanded affix "><li class="spacer"></li><li class="chapter-item expanded affix "><a href="../api-redirect.html">Rust API Reference (rustdoc)</a></li></ol>
</div>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<!-- Track and set sidebar scroll position -->
<script>
var sidebarScrollbox = document.querySelector('#sidebar .sidebar-scrollbox');
sidebarScrollbox.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
sessionStorage.setItem('sidebar-scroll', sidebarScrollbox.scrollTop);
}
}, { passive: true });
var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll');
sessionStorage.removeItem('sidebar-scroll');
if (sidebarScrollTop) {
// preserve sidebar scroll position when navigating via links within sidebar
sidebarScrollbox.scrollTop = sidebarScrollTop;
} else {
// scroll sidebar to current active section when navigating via "next/previous chapter" buttons
var activeSection = document.querySelector('#sidebar .active');
if (activeSection) {
activeSection.scrollIntoView({ block: 'center' });
}
}
</script>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
</div>
<h1 class="menu-title">JellyTau Documentation</h1>
<div class="right-buttons">
<a href="../print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa fa-github"></i>
</a>
<a href="https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/../docs/specs/frontend-domain-model.md" title="Suggest an edit" aria-label="Suggest an edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
</div>
</div>
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
<h1 id="spec-remove-jellyfin-specific-models-from-the-frontend"><a class="header" href="#spec-remove-jellyfin-specific-models-from-the-frontend">Spec: Remove Jellyfin-specific models from the frontend</a></h1>
<blockquote>
<p><strong>Implementation status (branch <code>frontend-domain-model</code>, worktree
<code>../JellyTau-domain-model</code>):</strong> Catalog surface <strong>done</strong>. The frontend's <em>item
classification</em> and <em>time units</em> no longer speak Jellyfin:</p>
<ul>
<li><code>domain/</code> module is the single source of truth; <code>MediaKind</code> enum + isolated
<code>from_jellyfin</code> mapping. The model gained real distinctions the flat
<code>item_type</code> had hidden: <code>LiveChannel</code> / <code>ChannelItem</code> / <code>Channel</code>.</li>
<li>Every catalog <code>item.type === "..."</code><code>item.kind</code> (0 remaining in <code>src/</code>).</li>
<li>Catalog ticks → milliseconds (<code>durationMs</code>, <code>playbackPositionMs</code>);
<code>formatDuration</code> takes ms; progress bars are unit-consistent.</li>
<li>User-facing type badge → <code>kindLabel()</code>.</li>
<li>Old Jellyfin-named fields remain <strong>dual-carried</strong> on the wire so nothing broke.</li>
</ul>
<p><strong>Deferred (tracked, not done):</strong></p>
<ul>
<li><code>primaryImageTag</code><code>imageId</code> rename (naming-only; ~40 sites across catalog +
<code>PlayerMediaItem</code>/<code>MergedMediaItem</code>, the latter needing a Rust <code>image_id</code>
round-trip). Catalog <code>MediaItem</code> already has <code>imageId</code>.</li>
<li>Player/session/reporting tick math (<code>Queue</code>, <code>SessionCard</code>, <code>RemoteControls</code>,
<code>playbackReporting</code>, <code>playerEvents</code>) — crosses storage/Jellyfin <em>command
signatures</em> in ticks; needs those commands to accept ms (phase 4).</li>
<li><code>stream.type</code> (<code>mediaStreams[].type</code>) — Jellyfin stream vocabulary (phase 4).</li>
<li>Delete <code>playbackUnits.ts</code> / <code>jellyfinFieldMapping.ts</code> once their last
consumers migrate; drop the dual-carried fields once nothing reads them.</li>
</ul>
</blockquote>
<p><strong>Status:</strong> Partially implemented (catalog surface); see banner.
<strong>Requirements:</strong> Architectural (boundary integrity — CLAUDE.md core principles).
Allocate new DRs on acceptance; suggested: DR for the domain <code>MediaItem</code>/<code>MediaKind</code>
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
(see <a href="../requirements.html">requirements.md</a>). Relates to UR-007, UR-008, UR-034.
<strong>UX spec:</strong> n/a — zero user-visible behaviour change. This is a pure
architecture/boundary migration.
<strong>Supersedes / revises:</strong> none. Extends the boundary work started in
<a href="scoped-search-boundary.html">scoped-search-boundary.md</a> from <em>taxonomy</em> to the
<em>whole media model</em>.</p>
<h2 id="summary"><a class="header" href="#summary">Summary</a></h2>
<p>The frontend currently consumes Jellyfin's data model directly: <code>MediaItem</code> is a
Jellyfin DTO (<code>runTimeTicks</code>, <code>primaryImageTag</code>, <code>parentIndexNumber</code>, a
stringly-typed <code>type: string</code> carrying Jellyfin's item vocabulary), mirrored via
specta into <strong>36+ frontend files</strong>, with <strong>127 <code>item.type === "…"</code> string
comparisons across 23 files</strong> and two frontend utility modules
(<code>playbackUnits.ts</code>, <code>jellyfinFieldMapping.ts</code>) doing Jellyfin-specific unit and
field conversion in the presentation layer.</p>
<p>This spec defines a <strong>provider-neutral domain model</strong>, owned by Rust, that the
Jellyfin repository maps <em>into</em>. The frontend consumes only that model. When done,
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
names — remains in <code>src/</code>.</p>
<h2 id="motivation"><a class="header" href="#motivation">Motivation</a></h2>
<p>Two concrete problems, one strategic:</p>
<ol>
<li><strong>Boundary violation at scale.</strong> Per CLAUDE.md, the frontend is
presentation-only and Rust owns the domain. Today the <em>domain model itself</em> is
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
a "tick" is, what <code>primaryImageTag</code> means, and that <code>"Audio"</code> is a track. That
is domain knowledge in the wrong layer, 36 files deep.</li>
<li><strong>Fragility.</strong> <code>type: string</code> is unchecked: a typo (<code>"Epis0de"</code>) or a Jellyfin
rename fails silently at runtime with no compiler help, across 127 sites. Tick
math (<code>* 10_000_000</code>) duplicated frontend-side is a class of bug the backend
should have already resolved.</li>
<li><strong>Strategic (the reason we chose the ambitious target):</strong> a neutral domain
model is the precondition for <strong>ever supporting a non-Jellyfin backend</strong> (Plex,
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
shut.</li>
</ol>
<h2 id="layer-assignment"><a class="header" href="#layer-assignment">Layer assignment</a></h2>
<div class="table-wrapper"><table><thead><tr><th>Logic / responsibility</th><th>Layer</th><th>Why it belongs there</th></tr></thead><tbody>
<tr><td>Definition of the media domain model (<code>MediaItem</code>, <code>MediaKind</code>)</td><td><strong>Rust</strong></td><td>The canonical shape the whole app reasons about; must not be a provider's wire format.</td></tr>
<tr><td>Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, <code>"Audio"</code><code>Track</code>, <code>PremiereDate</code><code>releaseDate</code>)</td><td><strong>Rust</strong>, in the Jellyfin repository</td><td>Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain."</td></tr>
<tr><td>Tick arithmetic (<code>playbackUnits.ts</code>)</td><td><strong>Rust</strong></td><td>A Jellyfin unit. The frontend should never see ticks; it receives <code>durationMs</code>/<code>positionMs</code>.</td></tr>
<tr><td>Sort-field mapping (<code>jellyfinFieldMapping.ts</code>, <code>title→SortName</code>)</td><td><strong>Rust</strong></td><td>Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral <code>SortKey</code>.</td></tr>
<tr><td><code>MediaKind</code> classification (is this a track / album / episode?)</td><td><strong>Rust</strong></td><td>Derived from Jellyfin's <code>item_type</code>; the frontend receives the already-classified kind.</td></tr>
<tr><td>Choosing which kind renders as a card vs a list row; grid/list toggle; group order</td><td><strong>Frontend</strong></td><td>Pure presentation over the neutral <code>kind</code>. Changes only if the UI is redesigned.</td></tr>
<tr><td>Navigation decisions (<code>kind === Track &amp;&amp; albumId</code> → go to album)</td><td><strong>Frontend</strong></td><td>Presentation/routing over neutral fields.</td></tr>
</tbody></table>
</div>
<p><strong>Borderline calls, resolved:</strong></p>
<ul>
<li><em><code>MergedMediaItem</code></em> (the lightweight now-playing projection) is already
half-neutral (<code>title</code>, <code>artist</code>, <code>duration</code>) — it becomes a straightforward
subset of the new domain model, not a special case.</li>
<li><em>Context discriminators</em> <code>"album"</code>, <code>"playlist"</code>, <code>"remote"</code> (in <code>TrackList</code>,
playback context, sessions) are <strong>already domain-neutral</strong> — they are <em>our</em>
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
<code>item_type</code>. Only the Jellyfin item-type strings move.</li>
<li><em><code>mediaStreams[].type === "Audio"/"Subtitle"/"Video"</code></em> (track selection in
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
self-contained — deferred to a late phase, not phase 1.</li>
</ul>
<h2 id="design"><a class="header" href="#design">Design</a></h2>
<h3 id="single-canonical-model-one-location-isolated-mappings"><a class="header" href="#single-canonical-model-one-location-isolated-mappings">Single canonical model, one location, isolated mappings</a></h3>
<p>The domain model is defined <strong>once</strong>, in a dedicated top-level Rust module
<code>src-tauri/src/domain/</code>, and is the single source of truth shared across the
whole app:</p>
<pre><code>src-tauri/src/domain/
media.rs canonical MediaItem, MediaKind, and the other media types
from_jellyfin.rs Jellyfin DTO -&gt; 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
</code></pre>
<ul>
<li><strong>One definition.</strong> <code>domain::MediaItem</code> is <em>the</em> model. Rust (repositories,
player, downloads) uses it directly. The frontend uses the generated <code>bindings.ts</code>
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.</li>
<li><strong>Mappings live beside the model, never in consumers.</strong> All provider translation
(<code>JellyfinItem</code><code>domain::MediaItem</code>, ticks→ms, image-tag→id, item-type→<code>MediaKind</code>)
lives in <code>domain/from_jellyfin.rs</code>. It is the <em>only</em> place Jellyfin vocabulary
touches the domain type. Adding a second provider later means a new
<code>from_&lt;provider&gt;.rs</code> beside it — the model and every consumer stay untouched.</li>
<li><strong><code>domain</code> is a top-level module</strong> (not under <code>repository/</code>) because <code>MediaItem</code>
is used by <code>player/</code>, <code>download/</code>, and <code>playback_mode/</code> too — it is not
repository-specific.</li>
<li>The existing <code>JellyfinItem</code> DTO + <code>to_media_item()</code> in
<a href="../../src-tauri/src/repository/online.rs">online.rs</a> is the seam that already
exists; it <strong>moves</strong> into <code>domain/from_jellyfin.rs</code> and is enriched to do real
translation instead of copying <code>item_type</code> through.</li>
</ul>
<h3 id="the-domain-model-rust"><a class="header" href="#the-domain-model-rust">The domain model (Rust)</a></h3>
<pre><pre class="playground"><code class="language-rust"><span class="boring">#![allow(unused)]
</span><span class="boring">fn main() {
</span>// 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&lt;i64&gt;, // 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&lt;String&gt;, // was: primary_image_tag
pub backdrop_image_ids: Option&lt;Vec&lt;String&gt;&gt;,
pub overview: Option&lt;String&gt;,
pub genres: Option&lt;Vec&lt;String&gt;&gt;,
pub production_year: Option&lt;i32&gt;,
pub release_date: Option&lt;String&gt;, // was: premiere_date (ISO-8601)
pub community_rating: Option&lt;f64&gt;,
pub official_rating: Option&lt;String&gt;,
// Relationships — already neutral, kept.
pub album_id: Option&lt;String&gt;, pub album_name: Option&lt;String&gt;,
pub album_artist: Option&lt;String&gt;, pub artists: Option&lt;Vec&lt;String&gt;&gt;,
pub artist_items: Option&lt;Vec&lt;ArtistItem&gt;&gt;,
pub series_id: Option&lt;String&gt;, pub series_name: Option&lt;String&gt;,
pub season_id: Option&lt;String&gt;, pub season_name: Option&lt;String&gt;,
// Ordinal position — rename off Jellyfin's index vocabulary.
pub track_number: Option&lt;i32&gt;, // was: index_number
pub disc_number: Option&lt;i32&gt;, // was: parent_index_number
pub user_data: Option&lt;UserData&gt;,
pub media_streams: Option&lt;Vec&lt;MediaStream&gt;&gt;,
pub media_sources: Option&lt;Vec&lt;MediaSource&gt;&gt;,
pub people: Option&lt;Vec&lt;Person&gt;&gt;,
}
<span class="boring">}</span></code></pre></pre>
<p>The existing <code>JellyfinItem</code> DTO (already defined in <code>online.rs</code>, deserialized
from the Jellyfin JSON) <strong>moves into <code>domain/from_jellyfin.rs</code></strong> and stays
private to that module. Its <code>to_media_item()</code> — today a near-passthrough that
copies <code>item_type</code> straight across — is enriched into the single, tested place
that:</p>
<ul>
<li>classifies <code>item_type: String</code><code>MediaKind</code> (including the edge cases found in
the audit: <code>"ChannelFolderItem"</code><code>Channel</code>/<code>Folder</code> by <code>is_folder</code>,
<code>"TvChannel"</code><code>Channel</code>, <code>"Composer"/"Director"/"Writer"</code><code>Person</code>,
<code>"Video"</code><code>Movie</code> or a video leaf). Unknown strings map to <code>Folder</code> or a new
<code>Other</code> variant — <strong>decide at implementation; must not panic.</strong></li>
<li>converts <code>run_time_ticks</code><code>duration_ms</code> (<code>ticks / 10_000</code>).</li>
<li>maps <code>PremiereDate</code><code>release_date</code>, image tags → image ids.</li>
</ul>
<p><code>SortKey</code> enum + its Jellyfin field mapping (<code>jellyfinFieldMapping.ts</code> contents)
moves into the Jellyfin repository; the command takes a neutral <code>SortKey</code>.</p>
<h3 id="-the-search-event--dual-payload-rule-applies-again"><a class="header" href="#-the-search-event--dual-payload-rule-applies-again">🔴 The <code>search-event</code> / dual-payload rule applies again</a></h3>
<p>Every path that returns <code>MediaItem</code> — command returns <strong>and</strong> the <code>search-event</code>
and any other event payloads — emits the new domain shape. Both sides of a
twice-delivered result must match (same rule as
<a href="scoped-search-boundary.html">scoped-search-boundary.md</a>). Grep for <code>MediaItem</code> in
event definitions before declaring a phase done.</p>
<h3 id="frontend-after"><a class="header" href="#frontend-after">Frontend after</a></h3>
<ul>
<li><code>MediaItem</code>/<code>MediaKind</code> come from generated <code>bindings.ts</code>.</li>
<li><code>item.type === "Audio"</code><code>item.kind === "track"</code> (127 sites, mechanical).</li>
<li><code>runTimeTicks</code> usages → <code>durationMs</code>; <strong>delete <code>playbackUnits.ts</code></strong> (ticks no
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
they exist, which are not Jellyfin-specific).</li>
<li><code>primaryImageTag</code><code>imageId</code> through the existing image-URL command.</li>
<li><strong>Delete <code>jellyfinFieldMapping.ts</code></strong>; sort options send a neutral <code>SortKey</code>.</li>
<li>Assert with the boundary tripwire + a new grep (see acceptance).</li>
</ul>
<h2 id="phased-migration"><a class="header" href="#phased-migration">Phased migration</a></h2>
<p>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:</p>
<ol>
<li><strong>Establish the <code>domain/</code> module + enriched mapping, tests — no frontend
change yet.</strong> Create <code>src-tauri/src/domain/{media,from_jellyfin,mod}.rs</code>. Move
<code>JellyfinItem</code>/<code>to_media_item</code> in. Add <code>MediaKind</code> and the neutral fields to
<code>domain::MediaItem</code> as <em>additive, defaulted</em> fields, and populate them in the
mapping, while <strong>keeping the old Jellyfin-named fields too</strong> (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).</li>
<li><strong>Flip the wire shape.</strong> Commands + events emit the new <code>MediaItem</code>.
Regenerate <code>bindings.ts</code>. Frontend breaks to compile errors — fix them
mechanically (<code>type</code><code>kind</code>, values <code>"Audio"</code><code>"track"</code>, <code>runTimeTicks</code>
<code>durationMs</code>, <code>primaryImageTag</code><code>imageId</code>). This is the big mechanical PR;
<code>bun run check</code> is the driver.</li>
<li><strong>Delete the frontend conversion helpers</strong> (<code>playbackUnits.ts</code> ticks,
<code>jellyfinFieldMapping.ts</code>) and route sorting through the neutral <code>SortKey</code>.</li>
<li><strong>Stream vocabulary</strong> (<code>mediaStreams[].type</code>) and any remaining stragglers;
tighten the boundary check to forbid Jellyfin item-type strings in <code>src/</code>
outside tests.</li>
</ol>
<p>Ship 1 → 2 → 3 → 4 as separate PRs. Do <strong>not</strong> attempt all four at once.</p>
<h2 id="out-of-scope"><a class="header" href="#out-of-scope">Out of scope</a></h2>
<ul>
<li>Actually adding a second backend (Plex/Subsonic). This spec only <em>unblocks</em> it.</li>
<li>Changing any user-visible behaviour, layout, or copy.</li>
<li>The player-internal <code>PlayerMediaItem</code> / <code>MediaSessionType</code> shapes, except where
they carry the fields being renamed — align them in phase 2 only if the compiler
demands it.</li>
<li>Context discriminators (<code>"album"</code>, <code>"playlist"</code>, <code>"remote"</code>) — already neutral.</li>
</ul>
<h2 id="acceptance-criteria"><a class="header" href="#acceptance-criteria">Acceptance criteria</a></h2>
<ul>
<li><input disabled="" type="checkbox"/>
No Jellyfin item-type string (<code>"Audio"</code>, <code>"MusicAlbum"</code>, <code>"Series"</code>, …) is
compared against <code>.type</code>/<code>.kind</code> anywhere in <code>src/</code> (outside tests). Verify:
<code>grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/</code> returns nothing.</li>
<li><input disabled="" type="checkbox"/>
No <code>Ticks</code>, <code>runTimeTicks</code>, <code>primaryImageTag</code>, <code>PremiereDate</code>, or Jellyfin
sort-field name (<code>SortName</code>, <code>RunTimeTicks</code>, …) appears in <code>src/</code> outside
tests. <code>playbackUnits.ts</code> (ticks) and <code>jellyfinFieldMapping.ts</code> are deleted.</li>
<li><input disabled="" type="checkbox"/>
<code>MediaItem</code>/<code>MediaKind</code>/<code>SortKey</code> in the frontend come from <code>bindings.ts</code>.</li>
<li><input disabled="" type="checkbox"/>
The <code>From&lt;JellyfinMediaDto&gt;</code> mapping is total and never panics on an unknown
item type (Rust test with a garbage type string).</li>
<li><input disabled="" type="checkbox"/>
Behaviour is identical: same library/search/home rendering, same sorting,
same navigation, offline included.</li>
<li><input disabled="" type="checkbox"/>
Both command returns and event payloads carry the new shape (no flicker).</li>
<li><input disabled="" type="checkbox"/>
<code>bun run check</code>, <code>bun run test</code>, <code>bun run check:boundary</code> pass;
<code>cargo fmt</code>/<code>cargo clippy</code>/<code>bun run test:rust</code> pass; <code>bindings.ts</code> regenerated.</li>
</ul>
<h2 id="testing"><a class="header" href="#testing">Testing</a></h2>
<p><strong>Rust</strong> (<code>cargo test</code>): the <code>From&lt;JellyfinMediaDto&gt; for MediaItem</code> mapping is the
critical surface —</p>
<ul>
<li>every known <code>item_type</code> → correct <code>MediaKind</code> (table test over all 20 values
found in the audit, incl. <code>ChannelFolderItem</code>, <code>TvChannel</code>, <code>Composer</code>);</li>
<li>unknown type string → safe fallback, no panic;</li>
<li><code>run_time_ticks</code><code>duration_ms</code> (10_000 divisor), boundary/None cases;</li>
<li><code>SortKey</code> → Jellyfin field mapping (port <code>jellyfinFieldMapping.ts</code>'s cases).</li>
</ul>
<p><strong>Frontend</strong> (vitest): update the many tests asserting <code>.type</code>/<code>runTimeTicks</code>;
they become <code>.kind</code>/<code>durationMs</code>. <code>jellyfinFieldMapping</code>/<code>playbackUnits</code> tests are
deleted with their modules. Add a compose/render test proving <code>kind</code>-based
branching matches the old <code>type</code>-based branching for a representative mix.</p>
<h2 id="traces"><a class="header" href="#traces">TRACES</a></h2>
<p>Per <a href="../../CLAUDE.html">CLAUDE.md</a>: the domain type + mapping
<code>UR-007, UR-008 | &lt;new DR&gt;</code>; the tick/field hoist <code>&lt;new DR&gt;</code>; frontend migration
phases share the DRs of the capability each touches (don't invent per-file DRs).</p>
<h2 id="notes-for-the-implementer"><a class="header" href="#notes-for-the-implementer">Notes for the implementer</a></h2>
<ul>
<li><strong>This is the highest-collision change in the repo's history</strong> — 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
<code>git diff</code> before repairing anything (CLAUDE.md gotchas / project memory).</li>
<li>Phase 1 deliberately maps <em>back</em> to the old shape so it can land safely ahead of
the disruptive flip. Resist the urge to skip it.</li>
<li>IPC camelCase rules apply to the new enums/structs
(<a href="../architecture/04-type-sync-and-threading.html">04-type-sync-and-threading.md</a>):
<code>#[serde(rename_all = "camelCase")]</code>; tagged-enum tag convention; regenerate
<code>bindings.ts</code>, never hand-edit.</li>
<li>Reviewed against <a href="SPEC-REVIEW-CHECKLIST.html">SPEC-REVIEW-CHECKLIST.md</a> — the
Layer assignment table above is the load-bearing section.</li>
</ul>
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
<a rel="prev" href="../specs/scoped-search-boundary-implementation.html" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../specs/build-provenance.html" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
<a rel="prev" href="../specs/scoped-search-boundary-implementation.html" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
<a rel="next prefetch" href="../specs/build-provenance.html" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
</nav>
</div>
<script>
window.playground_copyable = true;
</script>
<script src="../elasticlunr.min.js"></script>
<script src="../mark.min.js"></script>
<script src="../searcher.js"></script>
<script src="../clipboard.min.js"></script>
<script src="../highlight.js"></script>
<script src="../book.js"></script>
<!-- Custom JS scripts -->
</div>
</body>
</html>