From 76e50802784dbcf7b3899871244f4bca977196d6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 11 Jun 2026 11:03:48 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20gate=20Phase=208=20on=20TrackEntity=20n?= =?UTF-8?q?ormalization=20(=C2=A78.0);=20fold=20review=20decisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §8.0 TrackEntity → Release/Track normalization as a breaking pre-requisite before Phase 8 UI. Fold in review decisions: Waveform tab removed (in-grid status column + per-row/page-level generate), ViewModel is DI-scoped (TracksViewModel pattern), BatchEdit confirmed as a new page sharing extracted sub-components. Dissolve the AlbumSummaryDto widening question (Release table supplies the fields directly). --- PLAN.md | 38 ++- product-notes/phase-8-cms-track-browser.md | 311 +++++++++++++++++---- 2 files changed, 279 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index c1a0383..2dda922 100644 --- a/PLAN.md +++ b/PLAN.md @@ -132,37 +132,45 @@ Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed ## Phase 8 — CMS Track Browser -Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (component decomposition, VM design, URL scheme, data contracts, open questions). Several decisions are still open there (notably whether to widen `AlbumSummaryDto`) — needs Daniel sign-off before build. +Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the `TracksViewModel` pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions). -### 8.1 URL scheme + mode toggle +**§8.0 is a hard pre-requisite gate** — a breaking `TrackEntity` normalization that must land before any UI work in §8.1–§8.5. It also dissolves the largest UI open question (the old `AlbumSummaryDto` widening — the new Release table supplies those fields directly). The Waveform Pre-Processing tab is **removed**, folded into an in-grid status column + per-row/page-level generate actions (see §8.2). Review decisions folded in 2026-06-11; §8.0's own open questions (nullable release FK, upload upsert flow, public `TrackDto` read shape) still need Daniel sign-off before that migration is cut. + +### 8.0 `TrackEntity` normalization (pre-requisite — must land before §8.1–§8.5) + +- **What:** Split the flat `TrackEntity` into two normalized tables. New **`ReleaseEntity`** holds release-cardinal data (`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `CreatedByUserId?`). Slimmed **`TrackEntity`** holds track-cardinal data only (`Id`, `ReleaseId` FK, `Release` nav, `EntryKey`, `TrackName`, `TrackNumber`, `OriginalFileName?`) — the release fields are removed from it. New `ReleaseDto`; `TrackDto` slims and gains `ReleaseId` + a Release access path; `AlbumSummaryDto` is retired in favour of `ReleaseDto`. +- **Why:** The flat schema duplicates release-level metadata on every track row — updating an album's cover art or artist means rewriting every track. Phase 8 introduces album-as-a-unit editing (Batch Edit, album-scoped delete), so the model should match the domain: a Release is first-class; Tracks belong to a Release. This collapses several §8 UI open questions (Album-mode parent rows become Release rows directly; no `GROUP BY`-derived summary). +- **Shape:** Breaking migration — create `releases`, populate from distinct `(album, artist)` groups in `tracks`, add + populate `release_id` FK, drop the redundant track columns. Consumer impact reaches the public client (`DeepDrftPublic.Client` reads the flat `TrackDto` fields for the gallery) as well as the CMS — flagged as the highest-risk consumer in the notes. Open questions: nullable release FK for album-less tracks (recommend yes), upload auto-create-or-find Release (recommend yes), flat-vs-nested `TrackDto` read shape for the public API (recommend flat read model). Full spec + migration steps + consumer-impact list: notes §0. + +### 8.1 URL scheme + mode toggle *(depends on §8.0)* - **What:** `/tracks` (Track mode, default), `/tracks/albums`, `/tracks/genres` as route segments; a toggle inside the existing "Tracks" tab switches mode and pushes the matching URL. The Waveform Pre-Processing tab is untouched. - **Why:** The public home page hard-codes these as cross-host deep-links; a route segment reads as a stable address and matches the app's existing segment-based routing (`/tracks/upload`, `/tracks/{id}`). Query-param mode (`?mode=`) was the alternative — rejected as transient-looking view state, optionally tolerated as an alias. - **Shape:** One `TrackList` component carrying three `@page` directives (or three thin wrappers passing an `InitialMode`); the toggle drives `Mode` + `NavigationManager.NavigateTo`. See notes §3, §9. -### 8.2 `CmsTrackGrid` — the reusable flat track table (DRY core) +### 8.2 `CmsTrackGrid` — the reusable flat track table (DRY core) *(depends on §8.0)* -- **What:** Extract today's `MudTable` into a standalone `CmsTrackGrid.razor` taking `AlbumFilter`/`GenreFilter` params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (`d MMMM, yyyy`) → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public `TrackCard` fallback pattern, defined locally CMS-side. -- **Why:** Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. -- **Shape:** Owns its own `MudTable` + `LoadServerData` + delete-confirm (lifted from `TrackList`). `GetPagedAsync` gains optional `album`/`genre` filter params — the one real data-contract change (the endpoint already supports the filters). Display date format is presentation-only; sort key stays the raw `DateOnly`. See notes §8, §11. +- **What:** Extract today's `MudTable` into a standalone `CmsTrackGrid.razor` taking `AlbumFilter`/`GenreFilter` params. Apply the new column layout: Track # → 40×40 art thumb → Track Name → Artist → Album → Genre → Release Date (`d MMMM, yyyy`) → **Waveform Status** → Actions. Entry Key + File Name move out of the grid into an Info-icon tooltip (monospace). Art thumb reuses the public `TrackCard` fallback pattern, defined locally CMS-side. +- **Why:** Single source of truth for the track-table layout — consumed by both Track mode (no filter) and Genre mode (genre filter), so no duplicated table markup. Decluttering Entry Key / File Name into a tooltip keeps the grid scannable while the data stays reachable. The Waveform column replaces the removed Waveform Pre-Processing tab (status visible inline; per-row Generate when no profile; page-level "Generate All Missing" in the Track-mode header). +- **Shape:** Owns its own `MudTable` + `LoadServerData` + delete-confirm (lifted from `TrackList`). `GetPagedAsync` gains optional `album`/`genre` filter params — the one filter data-contract change (the endpoint already supports the filters); post-§0 the filter joins through `releases`. Waveform status comes from a new `HasWaveformProfile` bool on `TrackDto` (recommended over a second per-page lookup; fold into the §8.0 DTO pass). Display date format is presentation-only; sort key stays the raw `DateOnly`. See notes §8, §9, §11. -### 8.3 Album mode +### 8.3 Album mode *(depends on §8.0)* -- **What:** `CmsAlbumBrowser` — parent album rows (art, name, artist, track count, genre, earliest release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track. -- **Why:** A scannable album catalogue is the CMS analogue of the public `AlbumsView`, and the natural place to manage a release as a unit. -- **Shape:** Parent rows from `GetAlbumSummariesAsync` (eager, once); child tracks lazy via `GetPagedAsync(album:)` on first expand, cached per row — no new endpoint. Expandable `MudTable` over `MudTreeView` (parent rows are multi-column, not tree-shaped). **Open:** the parent row needs artist/genre/date/type, which aren't on `AlbumSummaryDto` — either widen the DTO + summary query (recommended; fully-populated resting row) or derive lazily on expand (thin resting row, no contract change). Daniel's call. See notes §6, §10, §12(3). +- **What:** `CmsAlbumBrowser` — parent release rows (art, title, artist, track count, genre, release date, release-type chip, Edit + Delete) that expand to child track rows (track # + name only). Edit → Batch Edit page (§8.5); Delete → album-scoped delete of every track. +- **Why:** A scannable release catalogue is the CMS analogue of the public `AlbumsView`, and the natural place to manage a release as a unit. +- **Shape:** Post-§0, parent rows are `ReleaseEntity`/`ReleaseDto` rows — `GetReleasesAsync` (eager, once) supplies title/artist/genre/date/type directly, no derivation. Child tracks lazy via `GetPagedAsync(album:)` (joins through `releases`) on first expand, cached per row — no new endpoint. Expandable `MudTable` over `MudTreeView` (parent rows are multi-column, not tree-shaped). **The old `AlbumSummaryDto` widening question is dissolved by §8.0 normalization** — the Release table has all the fields, so the parent row is fully populated at rest with no DTO widening and no lazy derivation. See notes §6, §10, §0.5. -### 8.4 Genre mode +### 8.4 Genre mode *(depends on §8.0)* - **What:** `CmsGenreBrowser` — a responsive `MudCard` grid (one card per genre: name + track count); clicking a card expands it (accordion, one open at a time) to reveal a `CmsTrackGrid` filtered to that genre. - **Why:** CMS analogue of the public `GenresView`; the card-to-grid expand is the cheapest second mode because the grid is already built (§8.2). -- **Shape:** `GetGenreSummariesAsync` once; the expanded panel renders `CmsTrackGrid` with `GenreFilter` set and the Add button suppressed — zero duplicated table markup. See notes §7. +- **Shape:** `GetGenreSummariesAsync` once; the expanded panel renders `CmsTrackGrid` with `GenreFilter` set and the Add button suppressed — zero duplicated table markup. The embedded grid gets the waveform status column + per-row generate for free. See notes §7, §9. -### 8.5 Batch Edit page +### 8.5 Batch Edit page *(depends on §8.0)* -- **What:** New page `/tracks/album/{albumName}/edit`, reached from an Album-mode row's Edit action. `BatchUpload`'s master-detail mechanics with the album's data preloaded; submit swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks (new tracks still upload). Distinct from the existing single-track edit at `/tracks/{id}`. +- **What:** New page `/tracks/album/{albumName}/edit`, reached from an Album-mode row's Edit action. `BatchUpload`'s master-detail mechanics with the release's data preloaded; submit swaps per-row `UploadTrackAsync` for `UpdateAsync` on existing tracks (new tracks still upload). Distinct from the existing single-track edit at `/tracks/{id}`. - **Why:** Editing a release as a unit (rename tracks, reorder, swap cover, add tracks) without round-tripping the single-track editor per track. -- **Shape:** Recommend a *new* `BatchEdit.razor` sharing extracted sub-components with `BatchUpload` (album-header block, master list, detail pane) over growing `BatchUpload` with an `isEdit` flag — the flag breeds conditional soup across preload/detail/submit. Cover art uses the established upload-once-then-link-via-`UpdateAsync` two-step. **Open:** does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8,9). +- **Shape:** **Confirmed:** a *new* `BatchEdit.razor` sharing extracted sub-components with `BatchUpload` — album-header fields block (post-§0 edits the `ReleaseDto`), batch track list (move-up/down/remove + status chips), track detail pane — over growing `BatchUpload` with an `isEdit` flag (the flag breeds conditional soup across preload/detail/submit). Cover art uses the established upload-once-then-link-via-`UpdateAsync` two-step. **Open:** does remove-in-edit delete an existing track (with confirm) or just detach? See notes §10, §12(8). A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now. diff --git a/product-notes/phase-8-cms-track-browser.md b/product-notes/phase-8-cms-track-browser.md index 95b2b9d..71aefab 100644 --- a/product-notes/phase-8-cms-track-browser.md +++ b/product-notes/phase-8-cms-track-browser.md @@ -1,11 +1,151 @@ # Phase 8 — CMS Track Browser -Status: spec / one VM, three views. Several open decisions flagged in §10 — needs Daniel sign-off -before implementation. Author: product-designer. Date: 2026-06-11. +Status: spec / one VM, three views. Review decisions folded in 2026-06-11 (waveform-in-grid, +DI-scoped VM, BatchEdit extraction confirmed). **A data-model normalization (§0) is now a hard +pre-requisite — it must land before any Phase 8 UI work.** Author: product-designer. +Date: 2026-06-11. **Plan only — no code edits made by this doc.** -Cross-references: `PLAN.md §8` (the concise phase entry), `PLAN.md §6.2` (the deferred -"card-contextual filtering" item this phase supersedes), memory *One source, multiple views*. +Cross-references: `PLAN.md §8` (the concise phase entry, with §8.0 the normalization gate), +`PLAN.md §6.2` (the deferred "card-contextual filtering" item this phase supersedes), memory +*One source, multiple views*. + +--- + +## 0. Pre-requisite: Phase 8.0 — `TrackEntity` Normalization + +**This must land before any of §8.1–§8.5 UI work.** It is a breaking data-model change, not a UI +change, and every UI section below assumes the normalized schema. Daniel required this gate during +review on 2026-06-11. + +### 0.1 What + +Split the current flat `TrackEntity` into two normalized tables. + +**`ReleaseEntity` (new)** — release-cardinal data (the "header" shared across all tracks in a +release): + +| Field | Type | Notes | +|---|---|---| +| `Id` | `long` | PK | +| `Title` | `string` | the album/release name (currently `TrackEntity.Album`) | +| `Artist` | `string` | | +| `Genre?` | `string` | | +| `ReleaseDate?` | `DateOnly` | | +| `ImagePath?` | `string` | cover art | +| `ReleaseType` | `ReleaseType` enum | | +| `CreatedByUserId?` | `long` | | + +**`TrackEntity` (updated)** — track-cardinal data only: + +| Field | Type | Notes | +|---|---|---| +| `Id` | `long` | PK | +| `ReleaseId` | `long` (FK → `ReleaseEntity.Id`) | nullable — see open question (1) | +| `Release` | navigation property | | +| `EntryKey` | `string` | FileDatabase link (immutable) | +| `TrackName` | `string` | | +| `TrackNumber` | `int` | 1-based | +| `OriginalFileName?` | `string` | | + +*Removed from `TrackEntity`:* `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, +`ReleaseType`, `CreatedByUserId` — these now live on the Release. + +### 0.2 Why + +The current flat schema duplicates release-level metadata (artist, album, genre, release date, +cover art) on every track row. This was fine at launch but creates consistency hazards: updating an +album's cover art or artist name means rewriting every track row. Phase 8 introduces album-mode +editing (Batch Edit, album-scoped delete), so the data model should match the domain — a Release is +a first-class entity; Tracks belong to a Release. + +The normalization also collapses several open questions in the UI spec below: + +- `AlbumSummaryDto` becomes a simplified `ReleaseDto` (query the Release table, not `GROUP BY`). +- `CmsAlbumBrowser` parent rows map directly to `ReleaseEntity` rows — no derivation needed. +- The `AlbumSummaryDto` widening question (old §6 option ii / §12.3) **dissolves entirely** — the + Release table just has all the fields. +- Batch Edit's "header" = the Release record; "track list" = the Track records. + +### 0.3 Shape (direction, not implementation prescription) + +**Migration path (breaking):** + +1. Create `releases` table (from `ReleaseEntity`). +2. Populate `releases` from the distinct albums in `tracks` — one release row per distinct + `(album, artist)` identity (see open question 5). Tracks with a null album get a nullable FK or a + synthetic "Uncategorized" release (open question 1). +3. Add `release_id` FK column to `tracks`; populate from the newly created release rows. +4. Drop the now-redundant columns from `tracks` (`artist`, `album`, `genre`, `release_date`, + `image_path`, `release_type`, `created_by_user_id`). **This step is the breaking part.** + +**DTO impact:** + +- New `ReleaseDto` mirroring `ReleaseEntity`. +- `TrackDto` slims down: remove `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, + `ReleaseType`, `CreatedByUserId`. Add `ReleaseId` (long) and, for read paths that display release + data alongside track data, either a nested `Release` (`ReleaseDto`) or a flat denormalized read + model (open question 3). +- `AlbumSummaryDto` is retired / collapsed into `ReleaseDto` (open question 4). + +**Consumer-impact list (the most consumer-visible part of this change):** + +The existing `GET api/track/page` response shape changes. Callers that read `TrackDto.Artist` / +`.Album` / `.Genre` / `.ReleaseDate` / `.ImagePath` must adjust to read those from the Release +(`TrackDto.Release.Artist` / `.Release.Title` / …) or from a flat joined read model. Known +consumers: + +- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard` and the views feeding it) reads + `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` for display. **This is the highest-risk + consumer** — a public-facing surface, a different host, not part of the CMS phase otherwise. +- **`DeepDrftManager`** — the CMS (this phase's surface). + +**Service / repository impact (list, not prescription):** + +- `TrackRepository` — queries gain joins / `Include`s to load the Release. +- `TrackManager.GetPaged` — sorting by release fields (artist, album, genre, date) needs a join. +- `UnifiedTrackService.UploadAsync` — must find-or-create the Release before inserting the Track. +- `TrackContentService.AddTrackAsync` / the BatchUpload flow — accepts a Release (or release id) + rather than embedding album/artist/etc. on the track upload form. +- The upload API endpoint form fields split: separate "release" fields from "track" fields, or the + upload auto-creates the Release on first track and links subsequent tracks (open question 2). + +### 0.4 Open questions (need Daniel before this lands) + +1. **Tracks with no album.** Nullable `ReleaseId` (a "standalone track" concept), or does every + track require a Release (a release with null/empty title)? **Recommend nullable FK** — some tracks + genuinely have no album context. +2. **Upload flow change.** Does uploading now require the caller to first create/identify a Release, + or does the endpoint auto-create a Release if none exists for the given album name? + **Recommend auto-create-or-find** (upsert on `(title, artist)`) — keeps upload-form ergonomics + close to today's. +3. **`TrackDto` shape for the public API.** The public gallery reads flat `Artist` / `Album` / + `Genre` / `ReleaseDate` / `ImagePath`. Keep these queryable post-normalization via either a flat + joined read model (no nested object) or a nested `Release` property. **Recommend the flat + read-model for the public API** — no response-shape break if the flat fields stay populated from + the join. Call this out as the most consumer-visible impact. +4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes + `GetReleasesAsync` returning `List`. **Recommend retiring `AlbumSummaryDto`** in favor + of `ReleaseDto` — a direct replacement. +5. **Migration release identity.** Existing tracks are flat. The migration derives Release rows from + them. If two tracks share an album name but different artists, they'd map to two releases (or one + ambiguous one). **Recommend grouping by `(album, artist)`** as the release identity key — flag + this edge case explicitly in the migration. + +### 0.5 Impact on the Phase 8 UI sections below + +With normalization landed: + +- `CmsAlbumBrowser` parent rows **are** Release rows — the Release table has all the fields, so the + old §6 "derive artist/genre/date/type" problem and the §12(3) "widen the DTO" question both + disappear. +- Batch Edit's "header" is a `ReleaseDto` form; its "track list" is `List` (slim). +- The `GetPagedAsync` album/genre filter still works — it joins through the `releases` table. + +The UI sections (§1–§13) below are written against the pre-normalization vocabulary in places; +where they mention `AlbumSummaryDto` widening or deriving release fields from children, **read that +as resolved by §0** — the Release table supplies those fields directly. Net of §0, those UI +sections get *simpler*, not harder. --- @@ -17,6 +157,16 @@ The CMS `/tracks` page today is a single `MudTable` (the "Tracks" tab) toggle, each addressable by a distinct URL so the public home page can deep-link into a given mode. +**Pre-requisite gate:** all UI work in this phase sits on top of **§0 — the `TrackEntity` +normalization** (split into `ReleaseEntity` + slimmed `TrackEntity`). §0 is a breaking data-model +change that must land first; it also dissolves several of the open questions this spec originally +carried (notably the `AlbumSummaryDto` widening question — the Release table supplies those fields +directly). Read the UI sections below as written against the post-normalization schema. + +The Waveform Pre-Processing tab is **removed** (§9): waveform-profile status becomes an in-grid +status column on `CmsTrackGrid`, with per-row "Generate" actions and a page-level "Generate All +Missing" button replacing the old tab. + The architectural spine is **one view-model feeding three renderings**. This matches Daniel's standing preference (memory: *One source, multiple views* — "same data, different uses and ergonomics"): the divergence lives in layout, not in data paths. All three modes read from the @@ -37,11 +187,14 @@ existing data preloaded and the submit path swapped from upload to metadata upda ### What this is NOT - Not a public-site feature. This is CMS-only (`DeepDrftManager`, InteractiveServer). The public - home page only *links in*; it renders nothing from this phase. -- Not a new data model. `TrackDto`, `AlbumSummaryDto`, `GenreSummaryDto` already exist. + home page only *links in*; it renders nothing from this phase. **Caveat:** §0's normalization + changes the shared `TrackDto` contract, which *does* touch the public client — see §0.3 consumer + impact. The browse-mode UI is CMS-only; the data-model change is not. +- **Is** a data-model change — §0 introduces `ReleaseEntity` and slims `TrackEntity`. (The browse + *modes* introduce no new model; the pre-requisite does.) - Not a replacement for the existing single-track edit page (`/tracks/{id}`) — that survives, reached from Track Mode's per-row Edit. Batch Edit is a *second*, album-scoped editor. -- Not a change to the Waveform Pre-Processing tab (§9 covers how the modes coexist with it). +- The Waveform Pre-Processing tab **is removed** — its function moves into the grid (§9). --- @@ -136,9 +289,16 @@ for clarity (each wrapper is two lines; the body owns the VM) — but it's a sma ## 4. View-model design — `CmsTrackBrowserViewModel` -One scoped VM, injected into the page (or `@code`-owned if DI-scoping a VM is awkward in -InteractiveServer — see §10.5). It owns mode state and the load logic for all three modes. The -*views* are dumb; the VM is the single source of truth. +**Registered in DI as a scoped service and injected into the page** — matching the established +project pattern (`TracksViewModel` in `DeepDrftPublic.Client` is registered scoped in +`Startup.ConfigureDomainServices` and injected into `TracksView.razor`). The reason is dependency +hygiene: backend service dependencies (`ICmsTrackService` and the waveform service) live on the VM, +not in the component's `@inject` chain. The component injects only the VM; the VM holds the +services. It owns mode state and the load logic for all three modes. The *views* are dumb; the VM is +the single source of truth. + +(This resolves the earlier open question that floated a page-owned `@code` object — see §12.5. The +DI-scoped VM is the decided pattern, not the alternative.) ### 4a. State @@ -205,20 +365,23 @@ already-loaded summaries (cache for the page lifetime; a fresh page nav reloads) ``` TrackList.razor (@page "/tracks" + "/tracks/albums" + "/tracks/genres") -│ owns: CmsTrackBrowserViewModel, the mode toggle, the existing MudTabs shell +│ owns: (injects) CmsTrackBrowserViewModel [DI-scoped], the mode toggle, page header │ -├── MudTabs -│ ├── MudTabPanel "Tracks" -│ │ ├── mode toggle (Track | Album | Genre) — MudToggleGroup -│ │ ├── [Track mode] CmsTrackGrid (no filter) ← DEFAULT -│ │ ├── [Album mode] CmsAlbumBrowser (parent rows + lazy children) -│ │ └── [Genre mode] CmsGenreBrowser (card grid + accordion) -│ │ -│ └── MudTabPanel "Waveform Pre-Processing" ← UNCHANGED (§9) +├── page header +│ ├── mode toggle (Track | Album | Genre) — MudToggleGroup +│ └── "Generate All Missing" button (Track mode only) ← §9 │ -└── (navigates to) BatchEdit.razor (/tracks/album/{albumName}/edit) +├── [Track mode] CmsTrackGrid (no filter) ← DEFAULT +│ └── per-row: status icon + Generate (if no profile) + Edit/Delete/Info ← §9 +├── [Album mode] CmsAlbumBrowser (parent rows + lazy children) +└── [Genre mode] CmsGenreBrowser (card grid + accordion → CmsTrackGrid) + +(navigates to) BatchEdit.razor (/tracks/album/{albumName}/edit) ``` +The old `MudTabs` / "Waveform Pre-Processing" `MudTabPanel` is gone (§9). Whether a `MudTabs` shell +survives at all depends on whether any other panel needs it — likely not. + New components: ### 5a. `CmsTrackGrid.razor` — the single reusable flat track table (THE DRY core) @@ -239,9 +402,9 @@ the component consumed by **both** Track mode (no filter) and Genre mode (`Genre duplicated table markup, exactly the brief's DRY requirement. **Columns (new layout, §8 for detail):** Track # | Art (40×40) | Track Name | Artist | Album | -Genre | Release Date (`d MMMM, yyyy`) | Actions (Edit, Delete, **Info** tooltip carrying Entry Key -+ File Name). Entry Key and File Name columns are *removed* from the grid and moved into the Info -tooltip. +Genre | Release Date (`d MMMM, yyyy`) | **Waveform Status** | Actions (Edit, Delete, per-row +**Generate** when no profile, **Info** tooltip carrying Entry Key + File Name). Entry Key and File +Name columns are *removed* from the grid and moved into the Info tooltip. ### 5b. `CmsAlbumBrowser.razor` — Album mode @@ -338,7 +501,8 @@ one-source spine. The only new seam is the lazy-on-expand trigger. Applied in `CmsTrackGrid` (so Genre mode's embedded grid gets them for free): 1. **Column order (left to right):** Track # → Art thumb → Track Name → Artist → Album → Genre → - Release Date → Actions. (Track # is the very first column; thumb second; per the brief.) + Release Date → **Waveform Status** → Actions. (Track # is the very first column; thumb second; + per the brief. Waveform Status sits just before Actions — see item 7 and §9.) 2. **Track # column:** plain `@context.TrackNumber`. Header sortable on `TrackNumber` is optional (default sort stays Track Name asc to match today). 3. **Art thumb (40×40):** reuse the public `TrackCard` fallback pattern. @@ -374,21 +538,45 @@ Applied in `CmsTrackGrid` (so Genre mode's embedded grid gets them for free): precisely. The icon is an explicit, discoverable affordance.) 6. **Unchanged:** sort labels, paging (`PageSizeOptions { 10, 20, 50 }`), Add Track button (gated by `ShowAddButton`), Edit (→`/tracks/{id}`) and Delete (confirm dialog) actions. +7. **Waveform Status column (new — replaces the Waveform tab, see §9).** A status icon showing + whether a waveform profile exists for the track: `Icons.Material.Filled.CheckCircle` colored + `Color.Success` when present, `Icons.Material.Filled.Cancel` colored `Color.Warning` when absent. + This maps to the existing `HasProfile` boolean on `WaveformStatusDto`. **Data-contract decision + (flagged):** the grid binds `TrackDto`, which has no waveform status today. Two options — + (a) add `HasWaveformProfile` (bool) to `TrackDto`, or (b) the grid does a separate waveform-status + lookup and merges by `EntryKey`. **Recommend (a)** — one field, keeps the grid a single data + source rather than fanning out a second call per page. When `HasWaveformProfile` is false, the + Actions cell also shows a per-row **Generate** action button (only rendered when the profile is + missing). See §9. ---- +## 9. Waveform processing — tab removed, folded into the grid -## 9. Coexistence with the Waveform tab (integration detail the brief understates) +**Decision (review, 2026-06-11): the "Waveform Pre-Processing" `MudTabPanel` is removed entirely.** +Its three responsibilities relocate: -The mode toggle lives **inside the "Tracks" `MudTabPanel`**, above the rendered mode. The -"Waveform Pre-Processing" tab is a sibling `MudTabPanel` and is **untouched** — switching browse -modes never affects it; switching to the Waveform tab never affects browse mode. +1. **Per-track status** → the **Waveform Status column** in `CmsTrackGrid` (§8 item 7). A + success/warning icon driven by the profile-exists boolean. Every track's status is visible inline + while browsing, instead of behind a separate tab. +2. **Per-track generate** → a per-row **Generate** action in `CmsTrackGrid`, rendered only when the + track has no profile (`!HasWaveformProfile`). Generating refreshes the row's status. +3. **Bulk generate** → a **"Generate All Missing"** button in the **main tracks-page header area**, + visible when the user is in **Track mode**. This replaces the bulk-run button that lived in the + Waveform tab. -One wrinkle: the three browse-mode URLs (`/tracks/albums`, `/tracks/genres`) should land the user -on the **Tracks tab** with that mode active. They must not disturb tab selection if the user then -clicks over to Waveform. Simplest: the URL drives initial `Mode` and selects the Tracks tab on -load; tab and mode are independent state thereafter. Confirm this is the intended behaviour -(§10.7) — alternatively the modes could live *outside* the tabs entirely, but that's a bigger -restructure and the brief says keep the Waveform tab as-is. +With the tab gone, the `MudTabs` shell may collapse to a single surface (the mode toggle + the +rendered mode + the page header). Confirm whether `MudTabs` is retained for any other panel or +removed outright. The mode toggle and the "Generate All Missing" button both live in the Track-mode +page header. + +Because the status + generate affordances live in `CmsTrackGrid`, **Genre mode's embedded grid gets +them for free** — a genre's tracks show waveform status and per-row generate identically to Track +mode. The "Generate All Missing" page-level button is Track-mode-scoped (it operates over the whole +catalogue, not a filtered slice); whether it should also appear scoped-to-filter in Genre/Album mode +is a minor follow-up, not required for this phase. + +This depends on the §8 item-7 data-contract decision (recommend `HasWaveformProfile` on `TrackDto`) +so the grid has the status without a second per-page lookup — which dovetails cleanly with §0's +`TrackDto` rework (the field can be added as part of the normalization DTO pass). --- @@ -396,8 +584,11 @@ restructure and the brief says keep the Waveform tab as-is. `@page "/tracks/album/{AlbumName}/edit"` (URL-encoded album name as the route param). -**Recommendation on the component boundary: a new `BatchEdit.razor` page that shares extracted -sub-components with `BatchUpload.razor`, NOT a `BatchUpload` grown with an `isEdit` flag.** +**Component boundary — CONFIRMED (review, 2026-06-11): a new `BatchEdit.razor` page that shares +extracted sub-components with `BatchUpload.razor`, NOT a `BatchUpload` grown with an `isEdit` +flag.** Sub-components to extract: the album-header fields block (post-§0, edits the `ReleaseDto`), +the batch track list (master list with move-up/down/remove + status chips), and the track detail +pane. Both pages compose these with their own submit logic. Why not an `isEdit` parameter on `BatchUpload`: @@ -470,14 +661,18 @@ the confirm copy unambiguous about the blast radius. | Contract | Change | Why | Risk | |---|---|---|---| -| `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters | Low — additive, default-null keeps callers compiling | +| **`TrackEntity` / `ReleaseEntity`** (§0) | **Split** flat `TrackEntity` into `ReleaseEntity` + slim `TrackEntity` | normalization; pre-requisite gate | **High — breaking migration; see §0** | +| **`TrackDto`** (§0) | Slim down (drop release fields, add `ReleaseId` + Release access path) | follows the entity split | High — touches public + CMS consumers (§0.3) | +| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly | Medium | +| `ICmsTrackService.GetPagedAsync` | **Add** `string? album = null, string? genre = null` params (or an overload) | All three modes' filtered reads route through here; endpoint already supports the query filters. Post-§0 the filter joins through `releases` | Low — additive, default-null keeps callers compiling | | `CmsTrackService` (impl) | Pass `album`/`genre` through to the `api/track/page` query string | wire the above | Low | -| `AlbumSummaryDto` | **Recommended:** add `Artist?`, `Genre?`, `EarliestReleaseDate?`, `ReleaseType?` (§6 option ii) | Fully-populated parent rows without lazy expansion | Medium — touches the albums-summary query + DTO; alternatively defer (§6 option i) | -| albums-summary query (server) | compute the widened fields | as above | Medium — `GROUP BY` aggregation + uniform-value checks | +| `TrackDto.HasWaveformProfile` (bool) | **Add** (recommended over a second lookup, §8 item 7) | in-grid waveform status column without per-page fan-out | Low — additive; fold into the §0 DTO pass | +| `AlbumSummaryDto` | **Dissolved by §0** — replaced by `ReleaseDto`; `GetAlbumSummariesAsync` → `GetReleasesAsync`. (The old "widen the DTO" question is moot — the Release table has all the fields.) | normalization | — (replaced, not widened) | | New row models (`BatchEditRow`) | new, CMS-internal | edit mode | None (internal) | | Routes | `/tracks/albums`, `/tracks/genres`, `/tracks/album/{albumName}/edit` | mode deep-links + batch edit | Low | -No new API endpoints. No change to `TrackDto`. No public-site code (it only links in). +No new API endpoints (existing endpoints' *shapes* change per §0). The data-model change **does** +reach the public client (§0.3) — the browse-mode UI does not, but the `TrackDto` rework does. --- @@ -490,28 +685,34 @@ No new API endpoints. No change to `TrackDto`. No public-site code (it only link (multi-column: thumb, name, artist, count, genre, date, type, actions), which a tree node renders poorly. `MudTreeView` suits hierarchies of like items, not parent-rows-of-different-shape. Confirm. -3. **(§6/§11) Widen `AlbumSummaryDto`?** Recommend yes (option ii) so Album-mode parent rows show - artist/genre/date/type at rest. Alternative: derive lazily on expand (thin resting row, no DTO - change). **This is the biggest call** — it determines whether Album mode needs a server-side - query change or is pure CMS UI. -4. **(§6) Release-type chip derivation.** Trust first track's stored `ReleaseType`, fall back to a - count heuristic (1=Single, 2–5=EP, 6+=LP) only if absent? Recommend yes. -5. **(§4/§10.5) VM as DI-scoped service vs. page-owned `@code` object.** A scoped DI VM in - InteractiveServer is fine but adds wiring; a plain `@code`-owned object is simpler if the VM is - never shared across components. Recommend page-owned unless there's a reason to inject. *Small.* +3. **~~(§6/§11) Widen `AlbumSummaryDto`?~~ — DISSOLVED by §0.** The normalization gives Album mode a + real `ReleaseEntity`/`ReleaseDto` with artist/genre/date/type on it directly; there is no summary + DTO to widen and no derivation from children. The question is moot once §0 lands. +4. **(§6) Release-type chip derivation.** *Mostly dissolved by §0* — `ReleaseType` now lives on the + Release record, so the chip reads `Release.ReleaseType` directly. The count heuristic is only a + migration-time concern (deriving `ReleaseType` for legacy releases that had it per-track); flag in + the §0 migration, not the UI. +5. **~~(§4/§10.5) VM scoping~~ — RESOLVED.** The VM is **DI-registered as a scoped service** and + injected, matching the `TracksViewModel` pattern in `DeepDrftPublic.Client` (keeps backend service + deps off the component's inject chain). Not page-owned. See §4. 6. **(§8) Shared thumb component.** The 40×40 art+fallback thumb is near-identical to the public `TrackCard` thumb but in a different host. Define locally in `CmsTrackGrid` now; flag a future `DeepDrftShared.Client` extraction (Phase 7 territory) rather than coupling the CMS to the public client's CSS. Confirm "local now, share later." -7. **(§9) Mode URL vs. tab selection.** Confirm `/tracks/albums` lands on the Tracks tab with - Album mode active, and that tab and mode are independent thereafter (Waveform tab untouched). +7. **~~(§9) Mode URL vs. tab selection~~ — DISSOLVED.** The Waveform tab is removed (§9), so there + is no tab/mode independence question left. `/tracks/albums` simply lands the page in Album mode. + If a `MudTabs` shell survives at all, confirm what (if anything) the second panel is — likely + none. 8. **(§10) Remove-in-edit semantics.** Does removing an *existing* track row in Batch Edit delete the track (with confirm), or only detach it from the editing session? Recommend delete-with- confirm (a release editor that can't drop a track is half a tool), but it's a destructive action worth Daniel's explicit nod. -9. **Batch Edit component boundary (§10).** Confirm the extraction approach (shared sub-components, - new `BatchEdit` page) over an `isEdit` flag on `BatchUpload`. Recommend extraction; the flag is - the cheaper-diff alternative if Daniel prefers. +9. **~~Batch Edit component boundary (§10)~~ — RESOLVED.** Confirmed: a **new `BatchEdit.razor` + page** sharing **extracted sub-components** with `BatchUpload.razor` — *not* an `isEdit` flag on + `BatchUpload`. Sub-components to extract: the **album-header fields block**, the **batch track + list** (master list with move-up/down/remove + status chips), and the **track detail pane**. Both + `BatchUpload` and `BatchEdit` compose these with their own submit logic. (Post-§0, the + "album-header block" edits the `ReleaseDto`.) See §10. 10. **Home-page links.** Out of scope for this CMS phase, but: the public home page will hard-code `/tracks`, `/tracks/albums`, `/tracks/genres` (cross-host links into the Manager app). Confirm the Manager base URL is stable/known to the public site at build time (the URL *paths* are