16f356a760
Resolve Phase 8 open question 0.3 — TrackDto gets a nested Release (ReleaseDto); flat release fields removed, all consumers updated as part of §8.0 (flat read-model rejected). Add §0.6 implementation sequencing: five mergeable waves with Waves 1+2 as a single deployment unit and Waves 3+4 parallelizable. Update PLAN.md §8.0 Shape to match.
825 lines
51 KiB
Markdown
825 lines
51 KiB
Markdown
# Phase 8 — CMS Track Browser
|
||
|
||
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**, sequenced as five mergeable waves
|
||
(§0.6). Open Q3 resolved 2026-06-11: `TrackDto` gets a **nested `Release` (`ReleaseDto`)**, flat
|
||
release fields removed, all consumers updated as part of §8.0. 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, 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 a **nested `Release` (`ReleaseDto`)**
|
||
property — populated on reads, ignored on writes where only the FK matters. (Open question 3
|
||
**RESOLVED 2026-06-11**: nested `ReleaseDto`, not a flat read model. See §0.3 below.)
|
||
- `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` / `.ReleaseType` must adjust to read those from
|
||
the nested Release: `TrackDto.Release.Artist`, `.Release.Title` (was `.Album`), `.Release.Genre`,
|
||
`.Release.ReleaseDate`, `.Release.ImagePath`, `.Release.ReleaseType`. **This is not a "keep the flat
|
||
fields populated" no-op — the flat fields are removed and every reader is updated.** Known consumers
|
||
(full per-file enumeration in §0.6 below):
|
||
|
||
- **`DeepDrftPublic.Client`** — the public track gallery (`TrackCard`, `TrackDetail`,
|
||
`TrackMetaLabel`, `NowPlayingCard`) 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. Updated in Wave 3 (§0.6).
|
||
- **`DeepDrftManager`** — the CMS (this phase's surface). The existing `TrackEdit` / `TrackNew` /
|
||
`BatchUpload` forms read/write the flat fields. Updated in Wave 4 (§0.6).
|
||
|
||
**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.~~ — RESOLVED 2026-06-11.** `TrackDto` gets a **nested
|
||
`Release` (`ReleaseDto`)** property; the release-cardinal flat fields (`Artist`, `Album`, `Genre`,
|
||
`ReleaseDate`, `ImagePath`, `ReleaseType`, `CreatedByUserId`) are **removed** from `TrackDto` and
|
||
live only on `ReleaseDto`. The flat read-model alternative is **rejected** — denormalizing the
|
||
release fields back onto every `TrackDto` would re-introduce exactly the duplication §0
|
||
normalizes away, just at the contract layer instead of the table layer. All consumers that read
|
||
the flat fields are updated as part of §8.0 (Waves 3 + 4, §0.6) — this is mandatory, not
|
||
follow-on. This is the most consumer-visible impact of the phase; the consumer enumeration is
|
||
§0.6.
|
||
4. **`AlbumSummaryDto` fate.** With a Release table, `GetAlbumSummariesAsync` becomes
|
||
`GetReleasesAsync` returning `List<ReleaseDto>`. **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<TrackDto>` (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.
|
||
|
||
### 0.6 Implementation sequencing — appropriately sized waves
|
||
|
||
§8.0 is a breaking data-model change with a wide consumer blast radius, so it is sequenced into
|
||
independently-mergeable waves. The unit of mergeability is: *can this wave land on `dev` without
|
||
breaking the next wave's prerequisites?* Two waves (1 + 2) are a forced exception — flagged below.
|
||
|
||
**Wave 1 — Data model layer (no app-code changes yet).** *Touches only `DeepDrftData`.*
|
||
|
||
- `ReleaseEntity` (new). `TrackEntity` updated: add `ReleaseId` FK + `Release` navigation property,
|
||
remove the release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`,
|
||
`ReleaseType`, `CreatedByUserId`).
|
||
- `ReleaseConfiguration` (EF Core `IEntityTypeConfiguration`): table name, column names, FK
|
||
relationship, indexes. `TrackConfiguration` updated for the FK + removed columns.
|
||
- EF Core migration: create `releases` table; add `release_id` FK to `tracks`; **data migration** —
|
||
populate `releases` from distinct `(album, artist)` groups in existing `tracks` rows, then assign
|
||
the FK back to each track; finally drop the now-redundant columns from `tracks`.
|
||
- **CRITICAL — Wave 1 + Wave 2 are a single deployment unit.** Removing the entity fields breaks
|
||
every DTO mapper, repository projection, and service that reads them — the solution will not
|
||
compile at the end of Wave 1 alone. Wave 1 and Wave 2 must be developed and merged to `dev`
|
||
**together** (one PR, or a short-lived branch merged as a unit) before anything is built or run.
|
||
Do not attempt to land Wave 1 on its own.
|
||
|
||
**Wave 2 — DTOs, services, repositories, API layer.** *`DeepDrftModels`, `DeepDrftData`,
|
||
`DeepDrftContent`, `DeepDrftAPI`.* Deploys together with Wave 1.
|
||
|
||
- New `ReleaseDto` (mirrors `ReleaseEntity`).
|
||
- `TrackDto`: remove flat release fields; add `ReleaseId` (long) + `Release` (`ReleaseDto`,
|
||
populated on reads, ignored on writes where only the FK matters).
|
||
- `TrackRepository` (`GetPaged` / `GetById` / `Update` / etc.): queries JOIN `releases` and project
|
||
into the updated `TrackDto` with nested `Release`. Sort-by-release-field (artist/album/genre/date)
|
||
now sorts through the join.
|
||
- `TrackManager` / `ITrackService`: signature updates where the changed fields surface.
|
||
- `UnifiedTrackService.UploadAsync`: resolve a Release (**find-or-create by title + artist**) before
|
||
inserting the Track. The upload form still sends album/artist at the top level; the service layer
|
||
resolves the Release FK. (This is open question 2's recommended upsert, now committed.)
|
||
- `TrackController` endpoints: request/response shapes updated where they touch the changed fields.
|
||
- `AlbumSummaryDto` retired; `GetAlbumSummariesAsync` returns `List<ReleaseDto>` (rename to
|
||
`GetReleasesAsync`). `ICmsTrackService.GetAlbumSummariesAsync` replaced (or aliased) accordingly.
|
||
|
||
**Wave 3 — Consumer updates: `DeepDrftPublic.Client` (public site).** Can run in parallel with
|
||
Wave 4. Read off the nested `Release` everywhere the flat fields were read. Files verified against
|
||
source 2026-06-11:
|
||
|
||
| File | Flat fields read today | Change |
|
||
|---|---|---|
|
||
| `Controls/TrackCard.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (grid + row modes) | → `TrackModel.Release.Artist` / `.Release.Title` / `.Release.Genre` / `.Release.ReleaseDate` / `.Release.ImagePath` |
|
||
| `Pages/TrackDetail.razor` | `Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath` (masthead, cover, meta block, `hasMeta` guard) | same nested re-pointing |
|
||
| `Controls/AudioPlayerBar/TrackMetaLabel.razor` | `Artist`, `Genre`, `ReleaseDate` (player-bar label) | → `Track.Release.*` |
|
||
| `Controls/NowPlayingCard.razor` | `CurrentTrack.Artist`, `CurrentTrack.Album` | → `CurrentTrack.Release.Artist` / `.Release.Title` |
|
||
|
||
- `TrackCard.razor.cs`, `TracksGallery.razor.cs`, `TrackDetailViewModel.cs`,
|
||
`TrackDetail.razor.cs`: pass `TrackDto` through but **do not read the flat fields** — no change
|
||
needed beyond recompiling against the new contract. Listed so the implementer confirms rather than
|
||
assumes.
|
||
- **Not flat-field consumers (do not confuse):** `Pages/AlbumsView.razor` reads
|
||
`AlbumSummaryDto.Album` and `Pages/GenresView.razor` reads `GenreSummaryDto.Genre` — these are
|
||
*summary* DTOs, affected by the `AlbumSummaryDto` retirement in Wave 2, not by the `TrackDto`
|
||
flat-field removal. Reconcile them when Wave 2 settles the summary-DTO shape.
|
||
|
||
**Wave 4 — Consumer updates: `DeepDrftManager` (existing CMS surfaces).** Can run in parallel with
|
||
Wave 3. These must work against the normalized model *before* the Phase 8 UI work (Wave 5) starts.
|
||
|
||
| File | Today | Change |
|
||
|---|---|---|
|
||
| `Components/Pages/Tracks/TrackEdit.razor` | `TrackEditForm.From(track)` reads `track.Artist/.Album/.Genre/.ImagePath/.ReleaseDate/.ReleaseType`; `SaveAsync` calls `UpdateAsync` with the flat args | read from `track.Release.*`; `UpdateAsync` path follows Wave 2's signature (still passes album/artist, service resolves the Release) |
|
||
| `Components/Pages/Tracks/TrackNew.razor` | binds `_album`/`_artist`/`_genre`/`_releaseDate`, calls `UploadTrackAsync` + cover-link `UpdateAsync` | form fields can stay; the upload service now auto-resolves the Release (Wave 2). Binding unchanged externally, resolution changes internally |
|
||
| `Components/Pages/Tracks/BatchUpload.razor` | album-header flat fields sent on upload | same — service auto-creates/finds the Release; form fields stay, internal binding adjusts |
|
||
| `Components/Pages/Tracks/TrackList.razor` | the `MudTable<TrackDto>` columns bind `Artist`/`Album`/`Genre`/`ReleaseDate` | re-point to `.Release.*`. **Note:** this grid is superseded by §8.2 `CmsTrackGrid` in Wave 5 — update it minimally in Wave 4 only to keep `dev` green, since Wave 5 rebuilds it |
|
||
|
||
- Most CMS surfaces touched here are *rebuilt* in Wave 5 (Phase 8 UI). Wave 4's job is the minimum
|
||
to keep the existing CMS compiling and functional on the normalized model — not to polish surfaces
|
||
that Phase 8 replaces. Flag in each PR which edits are throwaway-pending-Wave-5 vs. durable.
|
||
|
||
**Wave 5 — Phase 8 UI (the original §8.1–§8.5).** Begins only after Waves 1–4 are merged and the
|
||
normalized model is stable on `dev`. `CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`,
|
||
`BatchEdit`, the mode toggle, and routing — all the UI work specced in §1–§13 below. The internal
|
||
sequencing *within* Wave 5 is §13.
|
||
|
||
**Dependency summary:** `[1 + 2 together] → {3, 4 in parallel} → 5`.
|
||
|
||
---
|
||
|
||
## 1. Summary
|
||
|
||
The CMS `/tracks` page today is a single `MudTable<TrackDto>` (the "Tracks" tab) plus a
|
||
"Waveform Pre-Processing" tab, both inside one `MudTabs`. This phase keeps that page and adds
|
||
**three browse modes** for the catalogue — **Track**, **Album**, **Genre** — selected by a
|
||
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
|
||
**same `ICmsTrackService.GetPagedAsync` path**; Album and Genre modes add summary calls
|
||
(`GetAlbumSummariesAsync` / `GetGenreSummariesAsync`) for their *parent* rows but reuse the same
|
||
paged-tracks call for their *detail* rows. No new API endpoint is introduced.
|
||
|
||
This phase also **supersedes the deferred §6.2** ("card-contextual filtering"). §6.2 was deferred
|
||
precisely because filtering needed an intermediate browse surface (a list of albums, a list of
|
||
genres) for the admin to pick from — "a CMS analogue of the public `AlbumsView`/`GenresView`."
|
||
Album Mode and Genre Mode *are* that surface. §6.2 should be marked superseded-by-§8, not left
|
||
dangling.
|
||
|
||
It also introduces one new page — **Batch Edit** (`/tracks/album/{albumName}/edit`) — reached
|
||
from an Album-Mode row's Edit action. This is `BatchUpload.razor`'s master-detail mechanics with
|
||
existing data preloaded and the submit path swapped from upload to metadata update.
|
||
|
||
### 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. **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.
|
||
- The Waveform Pre-Processing tab **is removed** — its function moves into the grid (§9).
|
||
|
||
---
|
||
|
||
## 2. Grounding — what exists today (verified, not paraphrased)
|
||
|
||
Read against the live source on 2026-06-11. Two facts diverge from the brief and are
|
||
load-bearing:
|
||
|
||
1. **`ICmsTrackService.GetPagedAsync` does NOT currently take album/genre filter parameters.**
|
||
Its signature is `GetPagedAsync(int page, int pageSize, string? sortColumn, bool sortDescending, CancellationToken ct)`.
|
||
The *underlying* `GET api/track/page` endpoint accepts `album=` / `genre=` query filters, and
|
||
`CmsTrackService` (the impl) could pass them — but the interface does not expose them today.
|
||
**This phase requires extending that signature** (see §6, data contracts). This is the one real
|
||
contract change. Flag it as the first implementation prerequisite.
|
||
|
||
2. **An existing single-track edit page already lives at `/tracks/{id}`.** Track Mode's per-row
|
||
Edit button (`Href="/tracks/{context.Id}"`) goes there. Album Mode's batch Edit is a *separate*
|
||
page. Do not conflate them; do not route the album edit through `/tracks/{id}`.
|
||
|
||
Other verified facts:
|
||
|
||
- `/tracks` (`TrackList.razor`) is `@attribute [Authorize]`, InteractiveServer, two
|
||
`MudTabPanel`s inside one `MudTabs`: "Tracks" (the `MudTable<TrackDto>` with `ServerData=LoadServerData`,
|
||
`RowsPerPage=20`) and "Waveform Pre-Processing".
|
||
- Current Track table columns: Track Name, Artist, Album, Genre, Release Date (`yyyy-MM-dd`),
|
||
Entry Key (monospace caption), File Name (monospace caption), Actions (Edit→`/tracks/{id}`,
|
||
Delete→confirm dialog → `DeleteTrackAsync`).
|
||
- `BatchUpload.razor` (`/tracks/upload`): album-header fields (Album Name, Artist, Genre,
|
||
Release Date as `YYYY-MM-DD` string, Release Type `MudSelect`, Cover Art `InputFile`), then a
|
||
master list of `BatchTrackRow` (WavFile, TrackName, Status) with move-up/down/remove + a
|
||
detail pane editing the selected row. Submit: optional one-shot cover-art upload
|
||
(`UploadImageAsync`) → per-row `UploadTrackAsync` → per-row `UpdateAsync` to link the image
|
||
(the upload endpoint takes no imagePath). `TrackNumber` is the 1-based list position (`i + 1`).
|
||
- `ICmsTrackService` already has: `GetPagedAsync`, `GetByIdAsync`, `UploadTrackAsync`,
|
||
`UpdateAsync(id, trackName, artist, album, genre, releaseDate, imagePath?, releaseType?, trackNumber?)`,
|
||
`DeleteTrackAsync(id)`, `UploadImageAsync`, `GetAlbumSummariesAsync`, `GetGenreSummariesAsync`,
|
||
`GetTrackCountAsync`, plus the waveform pair.
|
||
- `TrackDto` fields: `Id, EntryKey, TrackName, Artist, Album?, Genre?, ReleaseDate? (DateOnly),
|
||
ImagePath?, OriginalFileName?, ReleaseType, TrackNumber (int, 1-based)`.
|
||
- `AlbumSummaryDto`: `Album (string), TrackCount (int), CoverImageKey (string?)`.
|
||
- `GenreSummaryDto`: `Genre (string), TrackCount (int)`.
|
||
- Image URL pattern (from public `TrackCard.razor`): if `ImagePath` non-empty,
|
||
`background-image: url('api/image/{Uri.EscapeDataString(imagePath)}')`; else a CSS fallback
|
||
swatch. Images served unauthenticated at `api/image/{entryKey}`.
|
||
|
||
---
|
||
|
||
## 3. URL scheme — RECOMMENDATION: route segments, not query params
|
||
|
||
**Recommend: `/tracks` (Track, default), `/tracks/albums`, `/tracks/genres`** as route segments,
|
||
with `/tracks?mode=…` accepted as a tolerated alias if cheap.
|
||
|
||
Three options considered:
|
||
|
||
| Option | Form | Verdict |
|
||
|---|---|---|
|
||
| A. Query param | `/tracks?mode=albums` | Single `@page "/tracks"`, read `mode` from query. Simplest routing. |
|
||
| B. Route segment | `/tracks/albums` | Multiple `@page` directives on one component, or sub-routes. Cleanest URL, best deep-link target. |
|
||
| C. Separate pages | `/tracks`, `/albums`, `/genres` | Three components. Defeats the one-VM spine. Rejected. |
|
||
|
||
**Why B over A.** The brief's load-bearing requirement is that the *public home page hard-codes*
|
||
these URLs. A hard-coded link wants to be a stable, clean, semantically-obvious path. A route
|
||
segment (`/tracks/albums`) reads as a permanent address; a query param (`?mode=albums`) reads as
|
||
transient view state. Route segments are also the convention already in this app — `/tracks`,
|
||
`/tracks/upload`, `/tracks/{id}` are all segment-based, none use query params for navigation
|
||
state. Matching that convention keeps the URL space coherent.
|
||
|
||
**Why tolerate A as an alias.** Blazor lets one component carry multiple `@page` directives.
|
||
Adding `@page "/tracks"`, `@page "/tracks/albums"`, `@page "/tracks/genres"` to the one
|
||
`TrackList` component (the toggle just sets which mode renders) costs nothing and lets the
|
||
existing `?mode=` form (if any external link already uses it) keep working. If no such link
|
||
exists, drop the alias — don't carry dead surface.
|
||
|
||
**Concrete routing:**
|
||
|
||
```razor
|
||
@page "/tracks"
|
||
@page "/tracks/albums"
|
||
@page "/tracks/genres"
|
||
```
|
||
|
||
The component reads which segment routed it (via `NavigationManager.Uri` parse, or three thin
|
||
wrapper pages each setting an initial-mode parameter — see §10.1 for which). The toggle switches
|
||
mode *and* pushes the corresponding URL (`NavigationManager.NavigateTo("/tracks/albums")`) so the
|
||
address bar always reflects the current mode and deep-links round-trip.
|
||
|
||
**Decision needed (§10.1):** one multi-`@page` component reading the segment, vs. three trivial
|
||
wrapper pages passing an `InitialMode` enum into a shared body component. Recommend the latter
|
||
for clarity (each wrapper is two lines; the body owns the VM) — but it's a small call.
|
||
|
||
---
|
||
|
||
## 4. View-model design — `CmsTrackBrowserViewModel`
|
||
|
||
**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
|
||
|
||
```
|
||
enum BrowseMode { Tracks, Albums, Genres }
|
||
|
||
class CmsTrackBrowserViewModel
|
||
{
|
||
BrowseMode Mode { get; private set; } // current mode (drives which view renders)
|
||
|
||
// Track mode: the MudTable owns its own ServerData paging; the VM only holds the
|
||
// active filter (null for plain Track mode).
|
||
string? ActiveAlbumFilter { get; private set; } // set by Album-mode child expansion? No —
|
||
string? ActiveGenreFilter { get; private set; } // filters belong to CmsTrackGrid params, not VM state. See note.
|
||
|
||
// Album mode:
|
||
IReadOnlyList<AlbumSummaryDto> Albums { get; } // parent rows, loaded once on entering Albums mode
|
||
bool AlbumsLoading { get; }
|
||
// child tracks are NOT held in VM; each expanded album row owns its own lazy GetPagedAsync(album:) call.
|
||
|
||
// Genre mode:
|
||
IReadOnlyList<GenreSummaryDto> Genres { get; } // cards, loaded once on entering Genres mode
|
||
bool GenresLoading { get; }
|
||
string? ExpandedGenre { get; } // accordion: at most one expanded at a time
|
||
}
|
||
```
|
||
|
||
**Note on filters as VM state vs. component params.** There is a real design choice here. Two
|
||
readings:
|
||
|
||
- **VM-as-truth (heavier):** the VM holds `ActiveGenreFilter`, and `CmsTrackGrid` reads it. Then
|
||
the accordion's expansion is VM state.
|
||
- **Param-as-truth (lighter, recommended):** `CmsTrackGrid` takes `AlbumFilter`/`GenreFilter` as
|
||
`[Parameter]`s; the *view* passes the expanded genre/album down. The VM holds only `Mode`, the
|
||
summary lists, and `ExpandedGenre` (which genre card is open). The grid filter is derived from
|
||
`ExpandedGenre`, not stored separately.
|
||
|
||
**Recommend param-as-truth.** It keeps the VM small and keeps `CmsTrackGrid` genuinely reusable
|
||
(its filter is an input, not a read of ambient VM state). The VM owns *mode + summaries + which
|
||
thing is expanded*; the grid owns its own paging given a filter. This is the cleanest expression
|
||
of "one source, multiple views" — the *source* is `GetPagedAsync`, and the grid is the one view
|
||
component, parameterised.
|
||
|
||
### 4b. Which service calls happen where
|
||
|
||
| Trigger | Call | Owner |
|
||
|---|---|---|
|
||
| Enter Track mode | none up-front; `MudTable.ServerData` pages on demand | `CmsTrackGrid` |
|
||
| Track-mode page/sort | `GetPagedAsync(page, size, sort, desc)` | `CmsTrackGrid.LoadServerData` |
|
||
| Enter Album mode | `GetAlbumSummariesAsync()` once | VM |
|
||
| Expand an album row | `GetPagedAsync(album: name, …)` lazily, first expansion only | the album row (cached after first load) |
|
||
| Album row Edit | navigate to `/tracks/album/{name}/edit` | view |
|
||
| Album row Delete | confirm → N× `DeleteTrackAsync` (the album's track ids) | VM/view |
|
||
| Enter Genre mode | `GetGenreSummariesAsync()` once | VM |
|
||
| Expand a genre card | `CmsTrackGrid` mounts with `GenreFilter=name` → `GetPagedAsync(genre: name, …)` | `CmsTrackGrid` |
|
||
|
||
State transitions: mode change → push URL → if entering Albums/Genres and the summary list is
|
||
unloaded, fire the summary call; collapse any expanded child/genre. Re-entering a mode reuses the
|
||
already-loaded summaries (cache for the page lifetime; a fresh page nav reloads).
|
||
|
||
---
|
||
|
||
## 5. Component tree
|
||
|
||
```
|
||
TrackList.razor (@page "/tracks" + "/tracks/albums" + "/tracks/genres")
|
||
│ owns: (injects) CmsTrackBrowserViewModel [DI-scoped], the mode toggle, page header
|
||
│
|
||
├── page header
|
||
│ ├── mode toggle (Track | Album | Genre) — MudToggleGroup
|
||
│ └── "Generate All Missing" button (Track mode only) ← §9
|
||
│
|
||
├── [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)
|
||
|
||
The extracted Track-mode `MudTable<TrackDto>`. Single source of truth for the track-table layout.
|
||
|
||
| Parameter | Type | Default | Notes |
|
||
|---|---|---|---|
|
||
| `AlbumFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(album:)`. |
|
||
| `GenreFilter` | `string?` | `null` | When set, passed to `GetPagedAsync(genre:)`. |
|
||
| `ShowAddButton` | `bool` | `true` | The "Add Track" button. Off when embedded under a genre. |
|
||
| `PageSize` | `int` | `20` | Mirrors today's `RowsPerPage`. |
|
||
| `OnTracksChanged` | `EventCallback` | — | Raised after a delete, so a parent can refresh a count. |
|
||
|
||
Owns its own `MudTable` + `LoadServerData` + delete-confirm dialog (lifted verbatim from today's
|
||
`TrackList`). When `AlbumFilter`/`GenreFilter` is set, `LoadServerData` passes it through. This is
|
||
the component consumed by **both** Track mode (no filter) and Genre mode (`GenreFilter` set) — no
|
||
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`) | **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
|
||
|
||
Parent album rows (from `AlbumSummaryDto`) that expand to child track rows (lazy `GetPagedAsync`).
|
||
See §6 for the data flow. Uses an **expandable `MudTable` with a `RowTemplate` + per-row child
|
||
content**, not `MudTreeView` (see §10.2 recommendation). Each parent row: art thumb, album name,
|
||
artist (derived), track count, genre (derived), earliest release date, release-type chip, Edit +
|
||
Delete actions. Child rows: track # + track name only (a minimal inline template, not a full
|
||
`CmsTrackGrid` — the brief is explicit that album children are simpler).
|
||
|
||
### 5c. `CmsGenreBrowser.razor` — Genre mode
|
||
|
||
Responsive `MudCard` grid, one card per `GenreSummaryDto` (genre name + track count). Accordion:
|
||
clicking a card expands it (and collapses any other) to reveal a `CmsTrackGrid` with
|
||
`GenreFilter=genre, ShowAddButton=false` beneath/within. See §7.
|
||
|
||
### 5d. `BatchEdit.razor` — new page (§ batch edit below)
|
||
|
||
Reached from `CmsAlbumBrowser` row Edit. See dedicated section.
|
||
|
||
---
|
||
|
||
## 6. Album mode data flow — parent eager, children lazy
|
||
|
||
**Parent rows (eager):** on entering Album mode, the VM calls `GetAlbumSummariesAsync()` once.
|
||
Each `AlbumSummaryDto` renders one parent row. The cover thumb uses `CoverImageKey` with the same
|
||
`api/image/{Uri.EscapeDataString(key)}` fallback pattern.
|
||
|
||
**Derived parent-row fields.** `AlbumSummaryDto` gives only `Album`, `TrackCount`, `CoverImageKey`.
|
||
The brief asks the parent row to also show artist, genre, earliest release date, and a
|
||
release-type chip — **none of which are on the summary DTO.** Two ways to get them:
|
||
|
||
- **(i) Derive from children on expand only.** Parent row shows album name, track count, cover at
|
||
rest; artist/genre/date/type fill in *after* the row is expanded and the children load. Cheap,
|
||
but the parent row is informationally thin until expanded.
|
||
- **(ii) Widen `AlbumSummaryDto`.** Add `Artist?` (or "Various"), `Genre?` (or null→"—"),
|
||
`EarliestReleaseDate?`, `ReleaseType?` computed server-side in the albums-summary query. The
|
||
parent row is fully populated without expansion.
|
||
|
||
**Recommend (ii) — widen the summary DTO.** The parent row is meant to be a scannable album
|
||
catalogue; showing artist/genre/date there is the point of Album mode, and lazy-filling them on
|
||
expand makes the resting view feel broken. Computing them in the existing albums-summary SQL is a
|
||
modest server change (a `GROUP BY album` with `MIN(release_date)`, a uniform-artist check, etc.),
|
||
and it keeps the parent render synchronous. **This is a data-contract change to flag (§ open
|
||
questions / §10.3).** If Daniel would rather not touch the DTO, fall back to (i) and accept the
|
||
thin resting row.
|
||
|
||
**"Various" / "—" derivation rules** (wherever they're computed):
|
||
|
||
- Artist: if all tracks in the album share one artist → that artist; else `"Various"`.
|
||
- Genre: if uniform across the album → that genre; else `"—"`.
|
||
- Release date: earliest (`MIN`) track `ReleaseDate` in the album.
|
||
- Release-type chip: the brief says "infer from TrackCount + ReleaseType, or from first track."
|
||
Simplest defensible rule: use the album's tracks' `ReleaseType` if uniform; else fall back to
|
||
`TrackCount` heuristic (1 = Single, 2–5 = EP, 6+ = LP). Recommend: trust the stored
|
||
`ReleaseType` of the first track (it's set at upload for the whole release) and only use the
|
||
count heuristic if `ReleaseType` is absent. Flag as a small decision (§10.4).
|
||
|
||
**Child rows (lazy).** On first expansion of an album row, call
|
||
`GetPagedAsync(album: albumName, page: 1, pageSize: <large enough for one album>, sort: "TrackNumber")`.
|
||
Albums are small (a release is a handful of tracks), so a single page suffices — pick a page size
|
||
that comfortably exceeds any real album (e.g. 100) rather than paging within an album. Cache the
|
||
result on the row so re-expanding doesn't re-fetch. Child rows render **track number + track
|
||
name only** — no per-track Edit/Delete (the brief: editing is via album-level Batch Edit).
|
||
|
||
**This reuses the same `GetPagedAsync` path as every other mode** — no new endpoint, honouring the
|
||
one-source spine. The only new seam is the lazy-on-expand trigger.
|
||
|
||
---
|
||
|
||
## 7. Genre mode accordion
|
||
|
||
- `GetGenreSummariesAsync()` once on entering Genre mode → a responsive `MudCard` grid
|
||
(`MudItem xs=12 sm=6 md=4` or similar), one card per genre: genre name (`Typo.h6`), track count
|
||
(`Typo.body2`), and a fallback visual (genres have no cover art — use a neutral swatch or a
|
||
genre glyph).
|
||
- **Accordion, one open at a time.** Clicking a card sets `VM.ExpandedGenre = genre` (clicking the
|
||
open card again clears it). When a genre is expanded, render a `CmsTrackGrid` with
|
||
`GenreFilter=genre, ShowAddButton=false` **below the card grid** (a full-width panel under the
|
||
expanded card's row), not inside the card (a track table doesn't fit a card cell). The
|
||
expanded-card visual state (elevation/border) signals which genre's tracks are showing.
|
||
- Layout pattern borrowed from a **master-detail / expanding-panel accordion** (cf. macOS Finder
|
||
column expand, or MudBlazor's own `MudExpansionPanels` — though here the panel hosts a grid, so a
|
||
manual expand region under the card row reads better than nesting a table inside
|
||
`MudExpansionPanel`). Recommend a manual `@if (VM.ExpandedGenre == genre)` panel rendered after
|
||
the card grid, keyed to the expanded genre.
|
||
- The grid inside is the *same* `CmsTrackGrid` as Track mode — DRY satisfied: zero duplicated table
|
||
markup, the genre filter is just a parameter.
|
||
|
||
---
|
||
|
||
## 8. Track-mode grid changes (the `CmsTrackGrid` layout)
|
||
|
||
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 → **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.
|
||
- If `ImagePath` non-empty: a `div` 40×40 with
|
||
`background-image: url('api/image/@(Uri.EscapeDataString(context.ImagePath))'); background-size: cover; border-radius: 4px;`.
|
||
- Else: a neutral fallback swatch (mirror the public `deepdrft-track-row-thumb--fallback`
|
||
class — a CMS-side equivalent in `CmsTrackGrid.razor.css`). Do **not** depend on the public
|
||
client's CSS; define the CMS fallback locally (the public class lives in `DeepDrftPublic.Client`,
|
||
a different host). If the thumb/fallback is genuinely identical to the public one, that's a
|
||
candidate for a shared `DeepDrftShared.Client` component later — flag, don't force now (§10.6).
|
||
4. **Release Date format:** display `@context.ReleaseDate?.ToString("d MMMM, yyyy")` (→ "9 June,
|
||
2026"); `"—"` when null. Sorting is unchanged — the sort key is the raw `DateOnly`
|
||
(`SortLabel="ReleaseDate"` still maps to the date column server-side); the format is
|
||
presentation-only and does not touch the sort.
|
||
5. **Entry Key + File Name → Info tooltip.** Remove both columns. Add an **Info icon** in the
|
||
Actions cell (`Icons.Material.Filled.InfoOutlined`, `Size.Small`) wrapped in a `MudTooltip`
|
||
whose content shows both values monospaced:
|
||
```razor
|
||
<MudTooltip>
|
||
<TooltipContent>
|
||
<div style="font-family: monospace; text-align: left;">
|
||
<div>Entry: @context.EntryKey</div>
|
||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||
</div>
|
||
</TooltipContent>
|
||
<ChildContent>
|
||
<MudIconButton Icon="@Icons.Material.Filled.InfoOutlined" Size="Size.Small" />
|
||
</ChildContent>
|
||
</MudTooltip>
|
||
```
|
||
(Brief offered row-level tooltip *or* an info icon; recommend the **info icon in Actions** —
|
||
a row-wide tooltip fights the existing per-cell `DataLabel` hovers and is awkward to trigger
|
||
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
|
||
|
||
**Decision (review, 2026-06-11): the "Waveform Pre-Processing" `MudTabPanel` is removed entirely.**
|
||
Its three responsibilities relocate:
|
||
|
||
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.
|
||
|
||
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).
|
||
|
||
---
|
||
|
||
## 10. Batch Edit page
|
||
|
||
`@page "/tracks/album/{AlbumName}/edit"` (URL-encoded album name as the route param).
|
||
|
||
**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`:
|
||
|
||
- The two pages diverge in *enough* places that a flag breeds conditionals: edit preloads header +
|
||
rows; edit's track rows have no WAV slot for *existing* tracks but *do* allow adding new ones;
|
||
edit's submit is per-row `UpdateAsync` (existing) + `UploadTrackAsync` (newly added), where
|
||
upload is all `UploadTrackAsync`. A single component with `@if (isEdit)` scattered through the
|
||
master list, the detail pane, and `SubmitAsync` becomes hard to read and easy to break.
|
||
- But the *mechanics* are highly shareable: the album-header field block, the master list (rows
|
||
with move-up/down/remove + status chip), the detail pane, the cover-art picker, `FormatBytes`,
|
||
the row model. Those should be **extracted into shared sub-components / a shared partial** that
|
||
both `BatchUpload` and `BatchEdit` compose.
|
||
|
||
So: extract the reusable pieces (e.g. `AlbumHeaderFields.razor`, `BatchTrackList.razor`,
|
||
`BatchTrackDetail.razor`), and have both pages compose them with their own submit logic. This is
|
||
the same DRY-by-extraction move as `CmsTrackGrid`, applied to the editor. **Trade-off:** more files
|
||
and an upfront extraction cost on `BatchUpload` (which works today). If Daniel wants the smaller
|
||
diff, the `isEdit`-flag route is faster to ship but accrues the conditional-soup debt — call it
|
||
out, recommend extraction.
|
||
|
||
**Preload (edit mode):**
|
||
|
||
- Load the album's tracks via `GetPagedAsync(album: albumName, sort: "TrackNumber")` (same lazy
|
||
call Album mode uses).
|
||
- Header fields pre-filled from the first track: Album, Artist, Genre, Release Date, ReleaseType,
|
||
and cover art (`ImagePath` → preview via `api/image/{key}`).
|
||
- Master list shows existing tracks ordered by `TrackNumber`, each `TrackName` pre-filled, each
|
||
flagged **existing** (carries its `Id`, `EntryKey`; no WAV slot, shows current file name read-only).
|
||
- Newly added rows (via the same WAV `InputFile`) are flagged **new** and carry a WAV file like
|
||
today.
|
||
|
||
**Row model (extended):**
|
||
|
||
```
|
||
class BatchEditRow {
|
||
long? Id; // null = new track (upload), set = existing (update)
|
||
string? EntryKey; // existing only
|
||
string? OriginalFileName; // existing only, read-only display
|
||
IBrowserFile? WavFile; // new only
|
||
string TrackName;
|
||
int TrackNumber; // 1-based, reorderable
|
||
Status; ErrorMessage;
|
||
}
|
||
```
|
||
|
||
**Submit (edit mode):** for each row in order, `TrackNumber = position`:
|
||
|
||
- **Existing row (`Id` set):** `UpdateAsync(Id, TrackName, Artist, Album, Genre, ReleaseDate,
|
||
imagePath, ReleaseType, TrackNumber)`. Note `imagePath` is **tri-state** in `UpdateAsync` (null =
|
||
unchanged, "" = clear, value = set) — pass the (possibly newly uploaded) cover key to every row
|
||
so the whole album shares one cover, or `null` to leave each unchanged if cover wasn't touched.
|
||
- **New row (`Id` null):** `UploadTrackAsync(...)` then the cover-link `UpdateAsync` — exactly
|
||
today's `BatchUpload` path.
|
||
- If a new cover image was picked, upload it once first (`UploadImageAsync`), then apply its key
|
||
across all rows' `UpdateAsync`. Mirrors the existing one-shot-image pattern.
|
||
|
||
**Reorder + delete within the album** are natural extensions (the master list already has
|
||
move-up/down/remove). Removing an *existing* row should delete the track (`DeleteTrackAsync`) —
|
||
flag whether remove-in-edit deletes or just detaches (§10.8); recommend an explicit confirm before
|
||
deleting an existing track from within the editor.
|
||
|
||
**Album-row Delete (from `CmsAlbumBrowser`, not the editor):** confirm dialog scoped to the album
|
||
("Delete all N tracks in '{album}'? This removes metadata and audio for every track."), then
|
||
`DeleteTrackAsync` for each track id in the album. The brief specifies album-scoped delete; make
|
||
the confirm copy unambiguous about the blast radius.
|
||
|
||
---
|
||
|
||
## 11. Data contracts — summary of what changes
|
||
|
||
| Contract | Change | Why | Risk |
|
||
|---|---|---|---|
|
||
| **`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 flat fields, add `ReleaseId` + **nested `Release` (`ReleaseDto`)**) | follows the entity split; nested not flat (open Q3 resolved 2026-06-11) | High — touches public + CMS consumers; all updated in Waves 3 + 4 (§0.3, §0.6) |
|
||
| **`ReleaseDto`** (§0) | **New**, mirrors `ReleaseEntity`; retires `AlbumSummaryDto` | Album mode reads the Release table directly; also the nested read object on `TrackDto` | 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 |
|
||
| `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 (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.
|
||
|
||
---
|
||
|
||
## 12. Open questions (need Daniel before build)
|
||
|
||
1. **(§3) Routing mechanism.** Three wrapper pages passing `InitialMode`, vs. one multi-`@page`
|
||
component parsing the segment. Recommend wrapper pages. *Small call.*
|
||
2. **(§5b/§10.2) Album expand widget.** Recommend an expandable `MudTable` (parent `RowTemplate`
|
||
+ a child-row region toggled per row) over `MudTreeView` — the parent rows are tabular
|
||
(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`?~~ — 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~~ — 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)~~ — 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
|
||
settled here; the *host* resolution is a public-site config question, not this phase's).
|
||
|
||
---
|
||
|
||
## 13. Suggested sequencing (within the phase)
|
||
|
||
1. Extend `GetPagedAsync` with album/genre filters (unblocks everything).
|
||
2. Extract `CmsTrackGrid` from today's `TrackList` table + apply the §8 layout changes (Track
|
||
mode reaches parity — shippable on its own).
|
||
3. Add the mode toggle + routing + `CmsGenreBrowser` (Genre mode reuses `CmsTrackGrid` — cheapest
|
||
second mode).
|
||
4. `CmsAlbumBrowser` + the `AlbumSummaryDto` widening decision (§6) (the heaviest mode).
|
||
5. Extract `BatchUpload` sub-components + build `BatchEdit` (depends on Album mode's Edit action).
|
||
|
||
Steps 1–2 deliver visible value (cleaner Track grid) before any mode work lands, and each step is
|
||
independently mergeable.
|