535 lines
39 KiB
HTML
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 && 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 -> 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_<provider>.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<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>>,
|
|
}
|
|
<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<JellyfinMediaDto></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<JellyfinMediaDto> 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 | <new DR></code>; the tick/field hoist <code><new DR></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>
|