Files
deepdrft/product-notes/phase-8-cms-track-browser.md
daniel-c-harvey 16f356a760 docs: resolve TrackDto nesting (§0.3) and add §8.0 wave sequencing
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.
2026-06-11 11:09:24 -04:00

51 KiB
Raw Permalink Blame History

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 / Includes 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 14 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 MudTabPanels 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:

@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=nameGetPagedAsync(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, 25 = 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:
    <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; GetAlbumSummariesAsyncGetReleasesAsync. (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 §0ReleaseType 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.razornot 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 12 deliver visible value (cleaner Track grid) before any mode work lands, and each step is independently mergeable.