1410 lines
107 KiB
Markdown
1410 lines
107 KiB
Markdown
# Phase 11 — Public Site Enhancements
|
||
|
||
Status: spec / design. Author: product-designer. Date: 2026-06-15 (revised same day after Daniel
|
||
resolved the open questions and expanded scope); **2026-06-16 — added commitment 9 (release GUID
|
||
identifiers; §3e, wave 11.H).** **Plan only — no code edits made by this doc.**
|
||
|
||
Cross-references: `PLAN.md §11` (the concise phase entry), `PLAN.md §1.3` (preload/queue — now
|
||
**absorbed into this phase**), `product-notes/phase-9-release-medium-types.md` (the medium model —
|
||
`ReleaseMedium` enum, the per-medium detail-page strategy, the `ReleaseDetailScaffold` contract),
|
||
`COMPLETED.md §9.8` (Wave 8 remediation — the `/archive` release-cardinal browser, the inline nav,
|
||
the `TracksView` demotion), memory *One source, multiple views* and *Design for adaptability up
|
||
front*.
|
||
|
||
---
|
||
|
||
## 0. Why this phase exists, and the state it inherits
|
||
|
||
Phase 9 made the medium taxonomy real end-to-end and gave each medium a browse + detail surface.
|
||
Wave 8 then remediated the public side: `/archive` became a release-cardinal searchable browser,
|
||
the nav flattened to ARCHIVE + Cuts/Sessions/Mixes, and the track-cardinal `/tracks` gallery was
|
||
demoted from the nav (route kept). That work landed and is stable on dev (2026-06-13/14).
|
||
|
||
Phase 11 is the **next coherent pass over the public listening surface**. Daniel's hands-on use
|
||
surfaced an initial four commitments; on 2026-06-15 he resolved the open questions and expanded the
|
||
scope to **eight**; on 2026-06-16 he added a **ninth** — switch releases to GUID identifiers so the
|
||
id is no longer a transparent sequential integer. They share one spine: **make the release the
|
||
cardinal unit of the public site, make every navigation an addressable, shareable URL, and make the
|
||
album a first-class playable object** (ordered, queue-able, shareable) — and now, **a release that
|
||
can describe itself** in prose and **identify itself by an opaque, non-enumerable handle**.
|
||
The nine:
|
||
|
||
1. **Cuts detail page** (`/cuts/{id}`) — structural; the phase's center of gravity.
|
||
2. **Player-bar release-title → release detail** via a medium→route resolver — structural.
|
||
3. **Retire the whole track-cardinal stack** *and* **normalize release-card rendering into shared
|
||
components** — reduction + normalization (the heart of 11.C).
|
||
4. **Archive filters in the URL** — addressability polish.
|
||
5. **Explicit track-ordinal column** on the track model, editable from the CMS — **new scope**;
|
||
data-model + migration; gates correct `/cuts/{id}` ordering.
|
||
6. **Release-level Share** — **new scope**; a Cut/Session/Mix header Share shares the release URL.
|
||
7. **Play-queue system** — **new scope**; absorbs the deferred `PLAN.md §1.3`. The Cuts "play album"
|
||
affordance is its first consumer.
|
||
8. **Release Description field** — **new scope (2026-06-15)**; a multiline free-text field on the
|
||
base release (all media), edited from the CMS add/edit forms and rendered as a text block on every
|
||
release detail page. Unlike commitment 5, this **is** a genuinely new column + migration. See §3d.
|
||
9. **Release GUID identifiers** — **new scope (2026-06-16)**; the release id Daniel sees in URLs and
|
||
the API is a transparent sequential integer (`/cuts/1`, `/cuts/2`, …) that leaks catalogue size
|
||
and ordering. Switch releases to a GUID identifier "similar to how tracks are keyed," swept
|
||
end-to-end. **The reframe (§3e), RESOLVED by Daniel 2026-06-16: tracks did *not* change their PK
|
||
type — they front the int PK with an app-minted GUID *string* (`EntryKey`) as the public handle
|
||
and keep the int `Id` private. Releases get the *same member*: a new `ReleaseEntity.EntryKey`
|
||
(`string`, matching tracks) backfilled at migration time; the `long` PK stays DB-only, unused by
|
||
the app.** This is the chosen path (not just recommended) because the int PK is `BaseEntity.Id`
|
||
from the `Cerebellum.BlazorBlocks.Models` framework and is hardwired to `long` across
|
||
`Repository<>` / `Manager<>`. See §3e for the surface map, the framework constraint, the decisions
|
||
(gating one now resolved), and the sequencing (this wave **must follow 11.B–11.E** — it sweeps the
|
||
files they create/edit).
|
||
|
||
This is not a greenfield phase — most of the scaffolding it needs already exists (the medium browse
|
||
pages already share a `ReleaseGallery` card component; the detail pages already share
|
||
`ReleaseDetailScaffold`; the Archive already has all filter state). The structural new work is the
|
||
**Cuts detail page** and the **queue model** (the one real architecture decision). Everything else
|
||
is closing asymmetries and consolidating rendering that drifted into per-surface copies. The one
|
||
piece of genuinely new persisted state the phase introduces is the **release Description column**
|
||
(commitment 8) — a small, self-contained vertical slice (column → migration → DTO → write path →
|
||
CMS form input → detail-page text block) that touches no existing wave's surface and follows the
|
||
**exact channel Genre already uses** (release-cardinal field carried on the track update/upload
|
||
request, projected onto the linked release row — there is no dedicated release-update endpoint).
|
||
|
||
> **Headline correction on commitment 5 (read against live source, 2026-06-15).** Daniel asked for
|
||
> "an explicit ordinal column, editable from the CMS, with a Daniel-gated migration." **That column
|
||
> already exists and shipped in Phase 8.** `TrackEntity.TrackNumber` is an explicit 1-based, non-null
|
||
> ordinal (default 1) — *not* insertion-order — with its migration (`20260611005700`) **already
|
||
> applied**, its DTO mirror, its API write path (validated `> 0`), and CMS reorder
|
||
> (`BatchEdit` assigns ordinal from list position on submit). The read path already sorts on it
|
||
> (`ReleaseRepository.OrderBy(t => t.TrackNumber)`). **So commitment 5 carries no new column, no new
|
||
> migration, and no Daniel-gated apply step.** What remains is *verify-and-consume*: confirm
|
||
> `TrackDto.TrackNumber` is populated on the public read path the Cut page uses, and ensure
|
||
> `CutDetailViewModel` orders by it. The reorder-UX question Daniel asked me to surface is also
|
||
> already answered in the CMS — `BatchEdit` reorders by list position, not a numeric field. This
|
||
> reframing is the most important single change in this revision; §3a carries the detail. If hands-on
|
||
> use reveals a gap (e.g. the *public* track read does not project `TrackNumber`), that is a small
|
||
> wiring fix, not the schema project the brief anticipated.
|
||
|
||
> **The mirror-image note on commitment 8 (release Description; read against live source, 2026-06-15).**
|
||
> Where commitment 5 turned out *already built*, commitment 8 is the opposite: **no `Description`
|
||
> column exists** on `ReleaseEntity` or `ReleaseDto` (greps return nothing; the entity carries
|
||
> `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` and the two metadata
|
||
> satellites, no description). So commitment 8 **is** the real cross-stack schema project the brief's
|
||
> commitment-5 framing anticipated — just for a different field. The honest scope: a new
|
||
> `ReleaseEntity.Description` column + EF migration (**Daniel-gated apply**), the `ReleaseDto` mirror,
|
||
> the `TrackConverter` round-trip (both directions), the write-path plumbing (the
|
||
> release-cardinal-fields thread through `UpdateTrackMetadataRequest` / the upload form / the
|
||
> `UnifiedTrackService` and `UnifiedReleaseService` write — the **same path Genre travels**), the CMS
|
||
> `AlbumHeaderFields` multiline input, and the detail-page text block. §3d carries the full spec. This
|
||
> is a clean vertical slice that gates the detail-page render but shares no surface with 11.A–F — which
|
||
> is why it lands as its own wave (11.G), not a graft onto an existing one.
|
||
|
||
### What already exists (verified against live source, 2026-06-15)
|
||
|
||
| Surface | State | File |
|
||
|---|---|---|
|
||
| `/sessions/{id}` detail | **Exists, mature.** Hero-dominant overlay; bridged prerender; play wiring. | `Pages/SessionDetail.razor` |
|
||
| `/mixes/{id}` detail | **Exists, mature.** Full-page WebGL waveform background + scaffold; bridged prerender. | `Pages/MixDetail.razor` |
|
||
| `ReleaseDetailScaffold` | **Exists.** Invariant trio (back link, masthead, play/share) + `Hero`/`MetaContent` slots. Composed by `TrackDetail` and `MixDetail`. | `Controls/ReleaseDetailScaffold.razor` |
|
||
| `ReleaseDetailBase` | **Exists.** Shared load + prerender-bridge for single-release detail pages (id-addressed, resolves the playable track via `releaseId`-filtered track page). | `Pages/ReleaseDetailBase.cs` |
|
||
| `/cuts` gallery | **Exists** as `AlbumsView` (medium-parameterized card grid). Cards open `/tracks?album={title}`. **Renders its card markup inline (`album-card`), does NOT use `ReleaseGallery`.** | `Pages/AlbumsView.razor` |
|
||
| `/sessions`, `/mixes` galleries | **Exist** as `SessionsView`/`MixesView`, both inheriting `MediumBrowseBase` and **both composing the shared `ReleaseGallery` card grid** (with a `DetailRoute` param). | `Pages/{Sessions,Mixes}View.razor` |
|
||
| `ReleaseGallery` | **Exists.** The shared release-card grid — cover (+ `--fallback`), title, artist. Cards link `<a href="/{DetailRoute}/{id}">`. **Consumed only by Sessions/Mixes today**; Archive and Cuts re-implement equivalent markup inline. | `Controls/ReleaseGallery.razor` |
|
||
| `/archive` browser | **Exists, release-cardinal.** Debounced search + medium chips + genre filter. Cards route per-medium via a private `DetailHref` switch. **Renders card markup inline (`archive-release-card`), does NOT use `ReleaseGallery`. Filters held in component fields, NOT in the URL.** | `Pages/ArchiveView.razor(.cs)` |
|
||
| `/track/{EntryKey}` detail | **Exists** as `TrackDetail` — the *track-cardinal* detail the player-bar title links to. | `Pages/TrackDetail.razor` |
|
||
| `/albums` | **Exists** as a permanent redirect → `/cuts`. | `Pages/AlbumsRedirect.razor` |
|
||
| `SharePopover` | **Exists, track-keyed.** Takes an `EntryKey`; "Copy link" + "Embed player". No release-level share target. | `Controls/SharePopover.razor` |
|
||
| Play queue / playlist | **Does not exist.** Player is single-slot (`StreamingAudioPlayerService` holds one `CurrentTrack`). No notion of "next." `PLAN.md §1.3` (preload + queue) is deferred — **now absorbed here**. | — |
|
||
| `TrackEntity` ordinal | **ALREADY EXISTS — landed in Phase 8.** `TrackEntity.TrackNumber` (int, 1-based, default 1, non-null), column `track_number`, migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**. `TrackDto.TrackNumber` mirrors it; `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber` + `TrackController` validate (`> 0`) and persist it; `BatchEdit` already sets it from reorderable list position on submit; `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. **Commitment 5 is not new schema — it is verify-and-consume.** | `TrackEntity.cs:17`, `TrackConfiguration.cs:37`, `BatchEdit.razor:192/225` |
|
||
| `ReleaseEntity` Description | **DOES NOT EXIST.** No `Description` member on `ReleaseEntity` or `ReleaseDto` (greps return nothing). The entity carries `Title`/`Artist`/`Genre`/`ReleaseDate`/`ImagePath`/`ReleaseType`/`Medium` + `SessionMetadata`/`MixMetadata` — no description. **Commitment 8 is the real new-column project** (column + migration + DTO + converter + write path + CMS input + detail block). | `ReleaseEntity.cs:13-30`, `ReleaseDto.cs:10-33` |
|
||
|
||
### Three framing corrections (the brief's vocabulary vs. the live routes)
|
||
|
||
The brief's framing is directionally right but uses route names that do not match the code. Naming
|
||
these up front so the implementer is not misled:
|
||
|
||
1. **There is no `/tracks/{id}` route.** The track-cardinal detail route is **`/track/{EntryKey}`**
|
||
(`TrackDetail`), keyed by the FileDatabase entry key (a string), not a numeric id. The player-bar
|
||
title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9). When the brief says
|
||
"`/tracks/{id}` becomes a pure router," the *intent* is: **the thing a release-title click lands
|
||
on should resolve medium → the correct dedicated detail page.** See §2 for what this actually
|
||
means against the live routes — the brief's "router" is better realized as a small **medium→route
|
||
resolver** than as a literal `/tracks/{id}` page, because the player bar carries a track, not a
|
||
release id.
|
||
|
||
2. **Cuts genuinely have no single-release detail page.** This is the real asymmetry. `/cuts`
|
||
(`AlbumsView`) cards and `/archive` Cut cards both open `/tracks?album={title}` — the track
|
||
gallery filtered to the album title. Sessions and Mixes route to dedicated `/sessions/{id}` /
|
||
`/mixes/{id}` pages. The new `/cuts/{id}` page (§3) closes this gap and is the heart of the phase.
|
||
|
||
3. **`/archive` filters are already in component state but not the URL.** `ArchiveView` holds
|
||
`_selectedMedium`, `_selectedGenre`, `SearchText` as private fields with no
|
||
`[SupplyParameterFromQuery]` and no `NavigateTo` on filter change. Requirement 4 is therefore a
|
||
**URL-binding pass over an existing browser**, not a new feature. The pattern to borrow already
|
||
lives next door: `TracksView` reads `?album=`/`?genre=`/`?q=` via `[SupplyParameterFromQuery]`
|
||
(`TracksView.razor.cs` lines 21–23).
|
||
|
||
---
|
||
|
||
## 1. The eight commitments (Daniel, faithful capture; decisions of 2026-06-15 folded in)
|
||
|
||
The original four (1–4) plus four Daniel added on 2026-06-15: three when he resolved the open
|
||
questions (5–7), and the release Description field (8) as a focused addition the same day.
|
||
|
||
1. **Player-bar release-title → release detail, via a medium→route resolver.** **DECIDED
|
||
(2026-06-15):** the release-title click resolves the release's `ReleaseMedium` → the correct
|
||
dedicated detail page (`/cuts/{id}` | `/sessions/{id}` | `/mixes/{id}`). Realized as a resolver
|
||
helper at click sites (the player bar already carries release id + medium), plus a thin
|
||
`/tracks/{id}` redirect page for bare-release-id deep links. (Was OQ1; resolved — see §2.)
|
||
|
||
2. **New `/cuts/{id}` page — album view.**
|
||
- Header section, **left-aligned**: release name, artist, genre, release year, plus **Play** and
|
||
**Share** buttons.
|
||
- Cover art **large-ish, right**, with a **theme border** around the image.
|
||
- Below: an **ordered track list** (ordered by the new ordinal column — commitment 5), each row
|
||
with a play button.
|
||
- Header Play **enqueues the whole album in ordinal order** (commitment 7) and starts track 1.
|
||
|
||
3. **Retire the whole track-cardinal stack + normalize release-card rendering.** **DECIDED
|
||
(2026-06-15):** retire `TracksView` / `TrackDetail` / `TrackCard` / `TracksGallery` and the
|
||
`/tracks` + `/track/{EntryKey}` routes entirely (not just the `?album` branch). **And** normalize
|
||
release-card presentation into shared component(s) consumed across `/archive`, `/cuts`,
|
||
`/sessions`, `/mixes` (and the Cuts detail track-row where applicable). The shared
|
||
`ReleaseGallery` already exists but only Sessions/Mixes use it — Archive and Cuts re-implement
|
||
equivalent markup inline. This is the normalization heart of 11.C. (Was OQ5; resolved — see §4.)
|
||
|
||
4. **Archive search params in the URL.** Search term, medium filter, genre filter all encoded in
|
||
`/archive?…` so filter actions create navigable history anchors (back/forward, shareable links).
|
||
|
||
5. **Explicit track-ordinal column, editable from the CMS.** **IN SCOPE (2026-06-15) — but
|
||
ALREADY SATISFIED by Phase 8.** Daniel asked for an explicit ordinal (NOT insertion-order),
|
||
editable from the CMS. The column (`TrackEntity.TrackNumber`, 1-based, non-null), its migration
|
||
(already applied), DTO mirror, API write path, and CMS reorder (`BatchEdit`) **all already exist**.
|
||
No new column, no new migration, no Daniel-gated apply step. Remaining work is *verify-and-consume*:
|
||
confirm the public read path projects `TrackNumber` and that `CutDetailViewModel` orders by it.
|
||
(Was OQ4 "do not assume into Phase 11"; Daniel reversed to in-scope — and the read confirms it is
|
||
already built. See §3a.)
|
||
|
||
6. **Release-level Share.** **NEW SCOPE (2026-06-15):** a Cut/Session/Mix header Share shares the
|
||
*release* URL, not a single track's embed. `SharePopover` is track-keyed today; add a
|
||
release-level share target. (Was an adjacent gap; promoted to scope. See §3b.)
|
||
|
||
7. **Play-queue system.** **NEW SCOPE (2026-06-15):** Daniel — "now is the natural time for that."
|
||
Absorbs the deferred `PLAN.md §1.3` (preload/queue). A queue model the player consumes; the Cuts
|
||
"play album" affordance (header Play → enqueue the release's tracks in ordinal order) is the first
|
||
consumer. Carries an unresolved architecture question (queue inside `IPlayerService` vs. a
|
||
separate orchestrating service) — framed with a recommendation in §3c; the final call is
|
||
staff-engineer's at implementation. (Was an adjacent gap; promoted to scope. See §3c.)
|
||
|
||
8. **Release Description field.** **NEW SCOPE (2026-06-15):** every release medium (Cut, Session, Mix)
|
||
gains a **Description** — a multiline / paragraph free-text field describing the release. It is a
|
||
**base `ReleaseEntity` field** (applies to all media uniformly; per the Phase 9 spine it belongs on
|
||
the base release, **not** a per-medium metadata satellite). Two surfaces: (a) the **CMS add/edit
|
||
forms** gain a multiline text input (in `AlbumHeaderFields`, alongside Genre/Release Date — base
|
||
fields, all media); (b) the **release detail pages** (`/cuts/{id}`, `/sessions/{id}`, `/mixes/{id}`)
|
||
gain a **text block** rendering it. Confirmed against live source: **the column does not exist**, so
|
||
this carries a real EF migration (Daniel-gated apply), DTO mirror, converter round-trip, and
|
||
write-path plumbing — the same channel Genre travels. See §3d. (Focused addition; lands as its own
|
||
schema slice, wave 11.G, with the render folded into 11.A + a small touch to Session/Mix detail.)
|
||
|
||
---
|
||
|
||
## 2. Requirement 1 reframed — the medium→detail resolver
|
||
|
||
**Recommendation: do not build a literal `/tracks/{id}` page. Build a `medium → detail route`
|
||
resolver and apply it at the two click sites that need it.** Here is why, and the three shapes
|
||
considered.
|
||
|
||
### What the player-bar title actually carries
|
||
|
||
The player bar holds a **`TrackDto`** (`TrackMetaLabel.razor`). A `TrackDto` carries a nested
|
||
`Release` (with `Id` and `Medium`). So at the click site we already know the release id *and* the
|
||
medium — there is no need for a round-trip "router page" that fetches the release to discover its
|
||
medium. The resolver is a pure function:
|
||
|
||
```
|
||
Release { Id, Medium } → "/cuts/{Id}" | "/sessions/{Id}" | "/mixes/{Id}"
|
||
```
|
||
|
||
### Three shapes
|
||
|
||
- **(i) Literal `/tracks/{id}` router page.** A routable component that fetches the release by id,
|
||
reads `Medium`, and `NavigateTo`s the dedicated page (`replace: true` so the router never sits in
|
||
history). *Cost:* a network round-trip and a flash of an empty page on every release-title click,
|
||
to rediscover a medium the caller already knew. Justified **only** if some entry point has *only*
|
||
a release id and not the medium (e.g. an external deep link to `/tracks/{id}`). *Keep this as a
|
||
thin fallback, not the primary path.*
|
||
|
||
- **(ii) Pure resolver helper, applied at click sites (RECOMMENDED).** A single
|
||
`ReleaseRoutes.DetailHref(ReleaseDto)` (or `(long id, ReleaseMedium medium)`) helper — **one
|
||
table, one location** — that every release-title / release-card click consumes. `ArchiveView`
|
||
already has a private `DetailHref` switch (lines 121–126); **promote it to the shared helper** so
|
||
the Archive, the player bar, and the new Cuts cards all route through one source. No round-trip,
|
||
no flash. This is the *One source, multiple views* discipline applied to routing.
|
||
|
||
- **(iii) Both.** The resolver helper (ii) is the primary path; a thin `/tracks/{id}` redirect page
|
||
(i) exists as the addressable fallback for bare-release-id deep links and for honoring the brief's
|
||
literal route. The redirect page consumes the *same* resolver helper.
|
||
|
||
**Recommend (iii): resolver helper as the spine, plus a thin `/tracks/{id}` redirect page that
|
||
reuses it.** This satisfies the brief's literal "`/tracks/{id}` is a pure router" wording *and* gives
|
||
the common case (player-bar click, where the medium is already in hand) a zero-round-trip path. The
|
||
redirect page is ~15 lines and shares the resolver, so it is not a second source of truth.
|
||
|
||
> **DECIDED (Daniel, 2026-06-15):** the player-bar title click now points at the **release detail**
|
||
> (via the resolver), **not** the track detail. `TrackMetaLabel`'s `<a href>` changes from
|
||
> `/track/{Track.EntryKey}` to `ReleaseRoutes.DetailHref(Track.Release)`. Implementation note: the
|
||
> link today sits on the **track name** (`TrackMetaLabel.razor` line 9). With the repoint it
|
||
> resolves to the release page; the artist/genre/year already render from `Track.Release`. This also
|
||
> removes `TrackDetail`'s last inbound link — with commitment 3 retiring the whole track-cardinal
|
||
> stack, `TrackDetail` and `/track/{EntryKey}` are deleted outright (§4).
|
||
|
||
### What "resolver" means for Cut
|
||
|
||
`DetailHref` for a Cut today returns `/tracks?album={title}`. After §3 lands, a Cut resolves to
|
||
**`/cuts/{Id}`** (id-addressed, consistent with Session/Mix). This repoint is the hinge between
|
||
requirements 1, 2, and 3 — see the dependency note in §6.
|
||
|
||
---
|
||
|
||
## 3. Requirement 2 — the `/cuts/{id}` album-detail page
|
||
|
||
This is the phase's center of gravity: the first **multi-track** release detail page. Sessions and
|
||
Mixes are single-track (their detail pages show one play affordance); a Cut is an album/EP/single
|
||
with an ordered track list.
|
||
|
||
### 3.1 Layout (Daniel's spec, literal)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ ← All cuts │
|
||
│ │
|
||
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
|
||
│ │ RELEASE NAME (h3) │ │ │ │
|
||
│ │ Artist (h6, primary) │ │ COVER ART │ │
|
||
│ │ Genre · 2025 │ │ (large-ish, │ │
|
||
│ │ │ │ theme border) │ │
|
||
│ │ [ ▶ Play ] [ ⤴ Share ] │ │ │ │
|
||
│ └─────────────────────────────┘ └──────────────────────┘ │
|
||
│ ↑ header content LEFT ↑ cover RIGHT │
|
||
│ │
|
||
│ ───────────────────────────────────────────────────────────│
|
||
│ 1. ▶ Track One 3:42 │
|
||
│ 2. ▶ Track Two 4:18 │
|
||
│ 3. ▶ Track Three 2:55 │
|
||
│ … │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Header left:** release name, artist, genre, release year, **Play** + **Share** buttons.
|
||
- **Cover right:** large, **theme border** around the image (a `deepdrft-`-prefixed border using a
|
||
palette token — mirror the existing cover treatments but with an explicit framed border, which is
|
||
the new visual element).
|
||
- **Track list below:** ordered rows, each with a play button. Row click / row play streams that
|
||
track.
|
||
- **Header Play** starts **track 1**.
|
||
|
||
### 3.2 Compose `ReleaseDetailScaffold`, or not? — the load-bearing design call
|
||
|
||
Phase 9 §5.3 established `ReleaseDetailScaffold` as the shared detail scaffold and committed to
|
||
"refactor `TrackDetail` onto it; per-medium variance rides slots." The scaffold owns the **invariant
|
||
trio**: back link, masthead (title + artist), play/share affordance. `MixDetail` composes it;
|
||
`SessionDetail` deliberately diverges (overlay layout). The question for Cuts:
|
||
|
||
- **(i) Compose the scaffold.** The Cut header *is* the invariant trio (title, artist, play, share) —
|
||
almost exactly what the scaffold provides. The cover goes in the `Hero` slot; the genre/year go in
|
||
`MetaContent`; the **track list** rides a new `BodyContent` slot (the scaffold has no body slot
|
||
today — adding one is the cheap, correct extension Phase 9 §5.3 anticipated: *"named slots are fine
|
||
where genuinely needed, e.g. `BodyContent` for the Cut/Album multi-track listing"*).
|
||
- **(ii) Bespoke page** (like `SessionDetail`). Full control over the left/right header split, but
|
||
duplicates the play/share wiring and the back-link/masthead the scaffold already owns.
|
||
|
||
**Recommend (i): compose the scaffold, add one `BodyContent` slot.** Two caveats that decide whether
|
||
this is clean or a fight:
|
||
|
||
1. **The header layout is left-content / right-cover; the scaffold's masthead is a top row.** The
|
||
scaffold today renders masthead-then-Hero vertically (`MixDetail` stacks cover below the
|
||
masthead). The Cut layout wants masthead and cover **side by side**. This is a **layout variance**,
|
||
and Phase 9 §5.3 is explicit: *"a boolean layout parameter on the scaffold is a design failure —
|
||
that variance belongs in a slot."* So the right answer is **not** a `HeroBesideMeta` flag on the
|
||
scaffold. Two clean options:
|
||
- **(a)** The Cut page supplies its whole left+right header as the page content and uses the
|
||
scaffold only for the back link + play/share wiring + body slot — i.e. the scaffold's masthead
|
||
is *one* arrangement and the Cut wants a different one, so the Cut composes a richer header into
|
||
a slot. Risk: the scaffold's built-in masthead then competes with the Cut's own header.
|
||
- **(b)** Generalize the scaffold's header region into a `Header` slot with the current
|
||
masthead+play row as the default content, so `MixDetail`/`TrackDetail` are unchanged (default)
|
||
and `CutDetail` supplies a left/right `Header`. This is the cleaner *One source* move but
|
||
touches the scaffold's shared contract.
|
||
- **Recommend (b)**, but flag it: it is a scaffold-contract change that ripples to every composer.
|
||
If Wave pressure makes (b) risky, **(ii) bespoke page** is the honest fallback — record it as
|
||
deliberate divergence (as `SessionDetail` already is) rather than bending the scaffold with a
|
||
boolean.
|
||
|
||
2. **The play/share affordance differs.** The scaffold renders a `PlayStateIcon` (icon toggle) +
|
||
`SharePopover`. Daniel's Cut spec says **Play and Share buttons** (labeled buttons, per the
|
||
mockup). If the Cut wants text buttons rather than the icon idiom, that is a **slot for the
|
||
affordance row**, not a scaffold edit. Minor — flag for Daniel whether the Cut header keeps the
|
||
icon idiom (consistency with Session/Mix) or uses labeled buttons (the literal spec wording).
|
||
|
||
### 3.3 Data path — the track list
|
||
|
||
The Cut page needs the release **and its ordered tracks**. Both primitives exist:
|
||
|
||
- **Release:** `IReleaseDataService.GetById(id)` → `ReleaseDto` (with `Title`, `Artist`, `Genre`,
|
||
`ReleaseDate`, `ImagePath`, `Medium`, `ReleaseType`, `TrackCount`).
|
||
- **Tracks:** the track-data service already supports a **`releaseId`-filtered** track page —
|
||
`ReleaseDetailViewModel.Load` uses `GetPage(pageNumber: 1, pageSize: 1, releaseId: …)` to resolve
|
||
the single track for Session/Mix. The Cut page issues the same call with a **larger page size**
|
||
(cover the whole album — `pageSize: 100` matches the gallery convention) to get the ordered list.
|
||
|
||
**Ordering. DECIDED (2026-06-15): order by the new explicit ordinal column** (commitment 5, §3a),
|
||
not insertion order. The Cut track list reads tracks for the release sorted ascending by ordinal.
|
||
This makes the ordinal column a **hard dependency of correct `/cuts/{id}` ordering** — 11.A's track
|
||
list cannot render in the right order until the column exists and the read sorts on it. See §3a for
|
||
the column's full cross-stack spec and §6 for the wave dependency (the ordinal work gates 11.A's
|
||
ordering, so it sequences first).
|
||
|
||
**New `CutDetailViewModel` vs. extend `ReleaseDetailViewModel`.** `ReleaseDetailViewModel` resolves
|
||
*one* track. The Cut page needs *many*. Two options:
|
||
|
||
- Extend `ReleaseDetailViewModel` with an optional `Tracks` collection populated when the medium is
|
||
Cut. Risk: the VM grows a medium conditional — the smell Phase 9 fought.
|
||
- A dedicated `CutDetailViewModel` (loads release + full track list). Cleaner SRP; the Cut detail is
|
||
genuinely a different shape (multi-track) from the single-release VM.
|
||
|
||
**Recommend a dedicated `CutDetailViewModel`** + a `CutDetail` page deriving the same prerender-bridge
|
||
discipline `ReleaseDetailBase` encodes (persist release + tracks across the prerender→WASM seam,
|
||
guard restore on id). If the bridge logic is substantial, consider generalizing `ReleaseDetailBase`'s
|
||
bridge into a shared base both single- and multi-track details use — but only if it doesn't force a
|
||
medium conditional into the base. Flag the choice; don't pre-commit the implementer.
|
||
|
||
### 3.4 Play wiring
|
||
|
||
- **Row play:** stream the row's track (the `TracksView.PlayTrack` idiom — toggle if already current,
|
||
else `SelectTrackStreaming`). Row play **also sets the queue context** to the album from that row
|
||
forward (so the queue continues into the rest of the album after a mid-album row start) — see §3c
|
||
for the enqueue semantics. If the queue work lands after 11.A's first cut, row play degrades
|
||
cleanly to single-track streaming (the `Design for adaptability up front` seam).
|
||
- **Header Play: enqueue the whole album in ordinal order, start track 1.** **DECIDED (2026-06-15):**
|
||
this is the first consumer of the queue system (commitment 7, §3c). Header Play calls the queue's
|
||
`PlayRelease(tracks-in-ordinal-order)` rather than a bare `SelectTrackStreaming(track1)`. Because
|
||
11.A and the queue (11.F) may land in either order, **design header Play as a single handler call
|
||
that swaps from `SelectTrackStreaming(Tracks.First())` to `Queue.PlayRelease(Tracks)` with no
|
||
other change to the page** — the seam Phase 11's earlier draft already anticipated, now made real
|
||
by bringing the queue into scope.
|
||
|
||
---
|
||
|
||
## 3a. Commitment 5 — the track-ordinal column (already built; verify-and-consume)
|
||
|
||
**Recommendation: do not spec a schema project. Verify the existing `TrackNumber` reaches the public
|
||
Cut read, and consume it.** The brief asked to "spec the implications across the stack" for a new
|
||
ordinal column. The honest answer from the read is that the stack is already wired end to end; the
|
||
only open risk is the *public* read projection.
|
||
|
||
### 3a.1 What already exists (each verified, 2026-06-15)
|
||
|
||
| Layer | State | Evidence |
|
||
|---|---|---|
|
||
| Entity | `TrackEntity.TrackNumber` — `int`, 1-based, non-null, default 1. Explicit ordinal, **not** insertion-order. | `TrackEntity.cs:17` |
|
||
| EF mapping | `track_number` column, configured. | `TrackConfiguration.cs:37` |
|
||
| Migration | `20260611005700_AddReleaseTypeAndTrackNumber` — **already applied** (it is in the snapshot and predates current `dev`). | `Migrations/` |
|
||
| DTO | `TrackDto.TrackNumber` mirrors it; `TrackConverter` maps both directions. | `TrackDto.cs:18`, `TrackConverter.cs:75/91` |
|
||
| API write | `UpdateTrackMetadataRequest.TrackNumber` (`int?`); `TrackController` validates `> 0` (400 otherwise), persists to the track row. Upload path resolves to 1 when unset. | `TrackController.cs:263/395-403` |
|
||
| CMS edit/reorder | `BatchEdit` loads tracks `sortColumn: "TrackNumber"`, holds them as a reorderable row list (`BatchRowModel.TrackNumber`), and **assigns each row its ordinal from list position on submit**. | `BatchEdit.razor:192/225` |
|
||
| Read sort (release tracks) | `ReleaseRepository.GetTracks` already `.OrderBy(t => t.TrackNumber)`. `TrackManager` sort switch includes `"TrackNumber"`. | `ReleaseRepository.cs:117`, `TrackManager.cs:122` |
|
||
|
||
### 3a.2 The reorder-UX question Daniel asked me to surface — already answered
|
||
|
||
Daniel asked: drag-reorder vs. numeric field, recommend the simplest shippable. **It is already
|
||
shipped, and it is neither a bare numeric field nor drag-and-drop — it is list-position ordinal
|
||
assignment:** `BatchEdit` presents the release's tracks as an ordered row list and writes
|
||
`TrackNumber = position` on submit. Editing order = reordering rows. That is the simplest correct
|
||
model (the ordinal is derived, never hand-typed, so it cannot collide or skip). **No change wanted
|
||
here** unless Daniel specifically wants drag affordance polish in the CMS — which is out of Phase 11
|
||
(CMS, not the public site) and should be raised separately if desired.
|
||
|
||
### 3a.3 The one real verify step — the *public* read projection
|
||
|
||
The CMS read (`CmsTrackService.GetPagedAsync`) sorts and carries `TrackNumber`. The Cut page uses the
|
||
**public** track-data service's `releaseId`-filtered page. The single concrete task for 11.A is to
|
||
**confirm that public read both sorts by `TrackNumber` and projects it onto `TrackDto`**, so
|
||
`CutDetailViewModel` renders rows in saved order and can label the track number. If the public read
|
||
already orders by `TrackNumber` (likely — it shares `TrackManager`'s sort switch), 11.A's ordering is
|
||
free. If it does not, the fix is a one-line sort argument, not a migration. **This is why the "ordinal
|
||
gates `/cuts/{id}` ordering" dependency in §6 collapses to a verification, not a wave.**
|
||
|
||
> **Net effect on the waves:** commitment 5 does **not** become its own wave. It folds into 11.A as a
|
||
> verification checklist item ("public read projects + sorts `TrackNumber`"). The brief's worry about
|
||
> a Daniel-gated migration does not apply — the migration already ran.
|
||
|
||
---
|
||
|
||
## 3b. Commitment 6 — release-level Share
|
||
|
||
**Recommendation: add a release-keyed mode to `SharePopover`, sharing the resolved release URL; keep
|
||
the track-keyed mode for any surface that still shares a single track.** This is an additive
|
||
extension of an existing control, not a new control.
|
||
|
||
### 3b.1 What `SharePopover` does today
|
||
|
||
`SharePopover` takes an `EntryKey` (a track) and offers "Copy link" + "Embed player." Both targets
|
||
are track-scoped. A Cut/Session/Mix header has no way to share *the release page* — only a track's
|
||
embed. After §2 every release has a canonical detail URL (`ReleaseRoutes.DetailHref`), so a
|
||
release-level share target now has a well-defined thing to copy.
|
||
|
||
### 3b.2 Three shapes
|
||
|
||
- **(i) New `ReleaseSharePopover` control.** Clean separation, but duplicates the popover chrome,
|
||
copy-to-clipboard plumbing, and styling. Two controls to keep in visual sync.
|
||
- **(ii) `SharePopover` gains a release mode (RECOMMENDED).** Add an optional release target
|
||
(`ReleaseDto` or `(long id, ReleaseMedium medium)`) alongside the existing `EntryKey`. When the
|
||
release target is set, "Copy link" copies `ReleaseRoutes.DetailHref(release)` (absolute URL); the
|
||
"Embed player" affordance is hidden or repurposed (a release page is not a single-track embed —
|
||
see the open sub-question below). One control, two modes, one source of clipboard/chrome logic —
|
||
the *One source, multiple views* discipline applied to share.
|
||
- **(iii) Make `SharePopover` polymorphic over a share-target abstraction** (`IShareTarget` with
|
||
`Title`, `Url`, `EmbedMarkup?`). Most general; over-built for two cases today. Note as the shape to
|
||
reach for **only if** a third share target appears (e.g. a playlist/queue share once §3c lands).
|
||
|
||
**Recommend (ii)** now, with (iii) noted as the refactor target if a third target appears. The Cut
|
||
header's Share button (the labeled-button-vs-icon question in §3.2.2 applies here too) opens the
|
||
popover in release mode.
|
||
|
||
### 3b.3 Open sub-question (surface, recommend)
|
||
|
||
- **Does release-share keep an "Embed player" option?** A release page is multi-track (Cut) or a
|
||
single hero track (Session/Mix). For Cuts there is no single embeddable track, so "Embed player"
|
||
should be hidden in release mode (copy-link only). For Session/Mix the release *is* effectively one
|
||
track, so embed could still make sense — but to keep the contract simple, **recommend release mode
|
||
is copy-link-only across all media** and the per-track embed stays available only where a track is
|
||
the share subject. Flag for Daniel; trivial either way.
|
||
|
||
---
|
||
|
||
## 3c. Commitment 7 — the play-queue system (absorbs `PLAN.md §1.3`)
|
||
|
||
This is the phase's **one real architecture decision** and Daniel has explicitly asked for a strong
|
||
steer while leaving the final call to staff-engineer. The brief's framing — "queue inside
|
||
`IPlayerService` vs. a separate orchestrating service" — is the right axis. Recommendation first,
|
||
then the two shapes, then the seams to the existing player and the §1.3 preload relationship.
|
||
|
||
**Recommendation: a separate `IQueueService` that orchestrates *above* the single-slot
|
||
`StreamingAudioPlayerService`. The player stays a single-slot device; the queue owns "what plays
|
||
next" and drives the player via its existing select-and-stream API.** This is the cleaner separation
|
||
and it matches the deferred §1.3's own framing ("the player is a single-slot device that a future
|
||
`PlaylistService` orchestrates above").
|
||
|
||
### 3c.1 The two shapes
|
||
|
||
- **(i) Queue inside `IPlayerService` (single-slot player gains a queue).** The player holds
|
||
`CurrentTrack` *and* a `Queue<TrackDto>` and a position pointer; `Next`/`Previous`/`enqueue` are
|
||
player methods. *Pro:* one service, no coordination; the player already owns the end-of-stream
|
||
event that would advance the queue. *Con:* the player's responsibility balloons from "stream one
|
||
track" to "manage a playlist," which is the SRP smell — and it forecloses the case where the queue
|
||
outlives a player instance or where a non-audio surface (a visible up-next list) wants the queue
|
||
state without coupling to the streaming device.
|
||
|
||
- **(ii) Separate `IQueueService` orchestrating above the player (RECOMMENDED).** `QueueService`
|
||
owns the ordered list, the current index, and `PlayRelease` / `Enqueue` / `Next` / `Previous` /
|
||
`Clear`. It drives playback by calling the player's existing `SelectTrackStreaming(track)`. It
|
||
subscribes to the player's end-of-stream / track-ended signal to auto-advance. *Pro:* the player
|
||
stays single-purpose (the load-bearing streaming seam in `CLAUDE.md` is untouched); the queue is
|
||
independently testable and independently observable (the player bar and a future up-next panel both
|
||
read `QueueService` state); it is the natural home for the §1.3 preload trigger (the queue knows the
|
||
next track, so it owns the "prefetch next at threshold" decision). *Con:* a coordination seam — the
|
||
queue must observe the player's track-ended event reliably (the player must expose one; verify the
|
||
current `OnTrackEnded`/equivalent hook exists or spec adding it).
|
||
|
||
### 3c.2 The player seam (what the queue needs from the player)
|
||
|
||
The queue orchestrator needs three things from `StreamingAudioPlayerService`, all of which are either
|
||
present or a small addition:
|
||
|
||
1. **A way to start a track** — `SelectTrackStreaming(track)` exists (the Cut row-play and
|
||
`TracksView` already call it). The queue calls the same path; **no new player surface.**
|
||
2. **A track-ended signal** — the queue auto-advances on natural end-of-stream. Verify the player
|
||
raises a track-ended/playback-complete event today; if it only exposes position/state, **the one
|
||
genuine player addition is a `TrackEnded` event** (or the queue polls "state == ended"). Spec this
|
||
as the single new player-side hook.
|
||
3. **State for the player bar** — "is there a next?", "is there a previous?" so the bar can enable
|
||
skip-forward / skip-back. The bar reads `QueueService.HasNext` / `HasPrevious`.
|
||
|
||
### 3c.3 Queue data model (minimal, shippable)
|
||
|
||
```
|
||
QueueService
|
||
IReadOnlyList<TrackDto> Items // ordered; the release's tracks for "play album"
|
||
int CurrentIndex // -1 when empty
|
||
TrackDto? Current => Items[CurrentIndex]
|
||
bool HasNext / HasPrevious
|
||
PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0) // Cuts header Play / row Play
|
||
Enqueue(TrackDto) / EnqueueRange(IEnumerable<TrackDto>)
|
||
Next() / Previous() // advance index, drive player.SelectTrackStreaming
|
||
Clear()
|
||
event Action QueueChanged // player bar re-renders skip affordances
|
||
```
|
||
|
||
`PlayRelease(tracks)` is the Cuts "play album" entry point (commitment 2's header Play): pass the
|
||
release's tracks **in ordinal order** (already sorted, §3a), `startIndex: 0`. Row play is
|
||
`PlayRelease(tracks, startIndex: clickedRow)` — start mid-album, queue continues to the end (§3.4).
|
||
|
||
### 3c.4 Player-bar UI
|
||
|
||
The player bar today shows the current track + transport. The queue adds **skip-forward** and
|
||
**skip-back** controls (enabled per `HasNext`/`HasPrevious`), wired to `QueueService.Next/Previous`.
|
||
Optional, deferred-within-phase: a visible **up-next list** (the queue's `Items` past `CurrentIndex`).
|
||
Recommend skip controls in scope; the up-next panel as a `[speculative]` follow-on so the queue
|
||
work ships without waiting on a new UI surface.
|
||
|
||
### 3c.5 Relationship to §1.3 preload — IN or OUT?
|
||
|
||
`PLAN.md §1.3` is two things bundled: (a) a **queue model** ("a notion of next track") and (b)
|
||
**preload/prefetch** (begin the next track's bytes during the current track's tail). Commitment 7
|
||
brings **(a) the queue model fully into Phase 11**. **Recommend (b) preload stays OUT of Phase 11** —
|
||
ship the queue (correct next-track semantics, skip controls, play-album) first; preload is a
|
||
*perceived-latency optimization on top of* a working queue, and it is the prerequisite for crossfade
|
||
(§1.4) and gapless (§1.5), which are their own later work. Bringing preload in now couples the queue
|
||
ship to a staged-decoder change in the streaming path (the most load-bearing seam in the system).
|
||
|
||
**Design the seam, defer the feature** (memory *Design for adaptability up front*): `QueueService`
|
||
knows the next track, so it is the natural owner of a future "prefetch next at threshold" trigger.
|
||
Spec the queue so that adding preload later is *adding a subscriber to the queue's "next is known"
|
||
state*, not restructuring the queue. Note in `PLAN.md §1.3` that the **queue half is absorbed into
|
||
Phase 11; the preload half remains deferred** there (and gates 1.4/1.5 as before).
|
||
|
||
---
|
||
|
||
## 3d. Commitment 8 — the release Description field (a real new column)
|
||
|
||
**Recommendation: add `Description` to the base `ReleaseEntity`, thread it through the existing
|
||
release-cardinal write channel exactly as `Genre` is threaded, and render it as a text block via the
|
||
detail-page slots already in scope.** This is a clean vertical slice — no new architecture, no new
|
||
endpoint — but unlike commitment 5, it **is** a genuine new column with a real migration. The Phase 9
|
||
spine decides its placement without debate: it applies to **all media uniformly**, so it lives on the
|
||
**base release**, not a per-medium satellite.
|
||
|
||
### 3d.1 Why the base release, not a satellite (the placement is not a judgment call)
|
||
|
||
Phase 9 (`ReleaseConfiguration` comment, lines 45–56) is explicit: the default home for medium-varying
|
||
data is a satellite metadata table; `ReleaseType` is the *one* allowed exception, justified solely by
|
||
`/cuts` read volume, and **"Future media MUST NOT copy this pattern."** Description is the easy case —
|
||
it does **not** vary by medium (every Cut, Session, and Mix has the same kind of prose blurb), so the
|
||
satellite question never arises. A field that is uniform across media belongs on the base table by the
|
||
same logic that puts `Genre` and `ReleaseDate` there. **No new satellite, no medium conditional, no
|
||
converter null-for-non-matching-medium dance** (contrast `ReleaseType`, which the converter nulls for
|
||
Session/Mix — Description needs none of that; it is carried verbatim for all media).
|
||
|
||
### 3d.2 What does NOT exist today (verified against live source, 2026-06-15)
|
||
|
||
| Layer | State | Evidence |
|
||
|---|---|---|
|
||
| Entity | **No `Description`.** `ReleaseEntity` has `Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`, `Tracks`, `SessionMetadata?`, `MixMetadata?`. | `ReleaseEntity.cs:13-30` |
|
||
| DTO | **No `Description`.** `ReleaseDto` mirrors the above + read-model `TrackCount`. | `ReleaseDto.cs:10-33` |
|
||
| EF config | No `description` column mapped. | `ReleaseConfiguration.cs:24-72` |
|
||
| Converter | `TrackConverter.Convert(ReleaseEntity)` / `Convert(ReleaseDto)` map every base field both directions; no `Description` line. | `TrackConverter.cs:19-65` |
|
||
|
||
So the column is genuinely new. **A grep for `[Dd]escription` across `ReleaseEntity.cs` returns
|
||
nothing** — there is no field to verify-and-consume (the commitment-5 outcome); this is the build.
|
||
|
||
### 3d.3 The write path — Description rides the Genre channel exactly
|
||
|
||
This is the load-bearing realization that keeps the slice small: **there is no dedicated
|
||
release-update endpoint.** Release-cardinal fields are carried on the *track* update/upload request and
|
||
projected onto the linked release row by the unified services. The CMS edits the whole release through
|
||
`BatchEdit`, whose `AlbumHeaderFields` owns `AlbumName`/`Artist`/`Genre`/`ReleaseDate`, and on submit
|
||
each track's `CmsTrackService.UpdateAsync(...)` / `UploadTrackAsync(...)` carries those
|
||
release-cardinal fields; `TrackController` then routes them to the linked release (see the
|
||
`PUT api/track/meta/{id}` contract: *"release-cardinal fields … update the linked release"*).
|
||
|
||
So Description travels the **identical thread** as Genre:
|
||
|
||
1. **`ReleaseEntity.Description` (string?, nullable)** — base table; EF `description` column, `HasMaxLength`
|
||
generous for paragraph prose (e.g. 2000–4000; pick a ceiling, mirror the `Genre`/`Title` `HasMaxLength`
|
||
idiom in `ReleaseConfiguration`). **Nullable** — existing rows migrate with `NULL`, no data migration.
|
||
2. **EF migration** — `dotnet ef migrations add AddReleaseDescription`. **Daniel-gated apply** (do not
|
||
auto-run `database update`; the migration is generated and committed, applied on Daniel's go).
|
||
3. **`ReleaseDto.Description` (string?)** — DTO mirror.
|
||
4. **`TrackConverter`** — add `Description = entity.Description` to `Convert(ReleaseEntity)` and
|
||
`Description = dto.Description` to `Convert(ReleaseDto)`. No null-for-medium dance (it is uniform).
|
||
5. **Write request plumbing** — add `Description` to the release-cardinal field set carried on the
|
||
track update + upload path: `UpdateTrackMetadataRequest` gains `string? Description`; the upload form
|
||
gains a `description` field; `TrackController` and `UnifiedTrackService` / `UnifiedReleaseService`
|
||
thread it onto the linked release **wherever `Genre` is already threaded** (the cleanest diff is
|
||
"find every `Genre` in the write path, add a sibling `Description`"). *Note the tri-state question:*
|
||
Genre is passed as a plain nullable today (whitespace → null). Description should follow the **same
|
||
posent** — empty input → `null`, no special tri-state (it is not the cover-art `ImagePath` case).
|
||
6. **CMS input** — `AlbumHeaderFields` gains a `MudTextField` with `Lines="4"` (multiline) labeled
|
||
"Description", bound via a `Description`/`DescriptionChanged` parameter pair, wired through
|
||
`BatchEdit` (`_description` field, seeded from `release?.Description` on load — mirror `_genre` at
|
||
`BatchEdit.razor:213`) and the `BatchUpload` create form. It sits in the base-fields block alongside
|
||
Genre/Release Date, **not** inside `MediumFields` (it is base, not medium-specific).
|
||
|
||
> **One honest call to surface (not a blocker):** the write path projects release-cardinal fields from
|
||
> *each track row* onto the shared release. For a multi-track Cut, every row carries the same
|
||
> Description, and the last write wins — which is already exactly how Genre/ReleaseDate behave, so
|
||
> Description inherits the existing semantics with no new edge case. No change wanted; just naming that
|
||
> the "release field carried per-track" model already in place covers Description for free.
|
||
|
||
### 3d.4 The read path — the detail-page text block
|
||
|
||
The detail pages already load a `ReleaseDto` (the Cut page via the new `CutDetailViewModel` §3.3;
|
||
Session/Mix via `ReleaseDetailBase`). Once `ReleaseDto.Description` is populated (3d.3 step 4), the
|
||
render is a **conditional text block** — show a paragraph when `Description` is non-empty, render
|
||
nothing when null (most existing rows will be null until re-edited). Placement per surface:
|
||
|
||
- **`/cuts/{id}` (11.A):** the Cut page is new, so the text block is part of its first build — a
|
||
paragraph block in the header column or just below it (Daniel's layout §3.1 has room below the
|
||
header / above the track list; recommend **below the header masthead, above the track-list divider**
|
||
so the prose introduces the album before the tracks). Folds into 11.A's `BodyContent`/header
|
||
composition with no extra wave.
|
||
- **`/sessions/{id}` and `/mixes/{id}` (existing pages):** a small additive touch — a description
|
||
text block in each page's `MetaContent` (or equivalent body region). `SessionDetail` is the
|
||
overlay-diverged page and `MixDetail` composes the scaffold, so the block lands slightly differently
|
||
in each, but both are a few lines of conditional markup, not a structural change.
|
||
|
||
**Styling:** a quiet paragraph — `Typo.body1`/`body2`, muted, respecting `white-space: pre-line` so
|
||
authored line breaks survive (the field is "multiline / paragraph"). Surface for Daniel only if he
|
||
wants markdown rendering vs. plain prose; **recommend plain text with preserved line breaks** for v1
|
||
(no markdown dependency, matches the "free-text field" framing).
|
||
|
||
### 3d.5 Wave placement — its own schema slice (11.G), render folded into 11.A
|
||
|
||
**Honest call on whether this rides existing waves or warrants its own.** It splits cleanly:
|
||
|
||
- The **schema + write path + CMS input** is a self-contained vertical that **shares no surface with
|
||
11.A–F** (it touches `ReleaseEntity`/`ReleaseDto`/`TrackConverter`/the write request/`AlbumHeaderFields`
|
||
— none of which the six existing waves modify) and carries the one Daniel-gated migration in the
|
||
phase. Grafting it onto 11.A (the Cut page) would muddy 11.A's dependency story (11.A depends only on
|
||
existing data primitives; bolting a migration onto it makes the Cut page wait on a schema apply it
|
||
doesn't otherwise need). So the schema slice is **its own wave, 11.G** — independent, can start cold,
|
||
and the *only* thing that gates it is Daniel's migration go-ahead.
|
||
- The **detail-page render** is a thin consumer that **rides 11.A** (the Cut block is part of the Cut
|
||
page's first build) **plus a small additive touch to the existing Session/Mix detail pages**. It
|
||
depends on 11.G having populated `ReleaseDto.Description`, but the render degrades cleanly (a null
|
||
Description simply renders nothing), so 11.A can ship its Cut page before 11.G lands and gain the
|
||
description block the moment 11.G does — the same **design-the-seam** discipline used for the queue.
|
||
|
||
This is the truthful decomposition: a standalone schema wave is warranted (clean vertical + the one
|
||
migration), but inventing a *second* wave for the render would be over-decomposition — the render is a
|
||
few lines folded into work already scoped. **11.G = the schema/write/CMS slice; the render rides 11.A
|
||
and a Session/Mix touch.**
|
||
|
||
---
|
||
|
||
## 3e. Commitment 9 — release GUID identifiers (the transparency reframe)
|
||
|
||
> **Daniel, verbatim (2026-06-16):** "The integer IDs for releases are too transparent — switch to
|
||
> GUID identifiers for releases instead of the int IDs, similar to how tracks are keyed. Implement
|
||
> this from the entity up and replace all the int IDs with the GUIDs."
|
||
|
||
**RESOLVED (Daniel, 2026-06-16): do NOT change the release primary-key type. Front the existing
|
||
`long` PK with an app-minted GUID *string* — a new `EntryKey` column, the *same member name and type
|
||
tracks use* — and make *that* the only release identifier the public site and API expose. The int
|
||
`Id` stays the internal PK, unused by the app; the GUID string becomes the addressable handle. This
|
||
is exactly, mechanically, what tracks already do with `EntryKey`, and the chosen name/type mirror
|
||
that for genuine consistency.** Daniel's words: *"long at the DB level with an app-level guid
|
||
`EntryKey` for the releases just like tracks. PK is not used by the app. Be consistent with naming.
|
||
Migrate the existing data to provide the entry key at migration time."* The brief's literal "replace
|
||
all the int IDs with GUIDs from the entity up" was the right *intent* (kill the transparent
|
||
sequential id in the URL) but, taken as "change the PK type to `Guid`," it collides head-on with a
|
||
framework constraint — and the "similar to how tracks are keyed" steer was the clue that the
|
||
non-colliding path is the intended one. The reframe is the headline; the surface map and the
|
||
declined alternative (a true PK swap) follow.
|
||
|
||
### 3e.1 How tracks are keyed today — the model to copy (read against live source, 2026-06-16)
|
||
|
||
A track has **two** identifiers, and only one is public:
|
||
|
||
| Identifier | Type | Origin | Exposure | Evidence |
|
||
|---|---|---|---|---|
|
||
| `TrackEntity.Id` | `long` (framework `BaseEntity.Id`) | DB identity column (`bigint`, `IdentityByDefaultColumn`) | **Private.** Used only by ApiKey-gated CMS ops (`PUT/DELETE api/track/{id:long}`). Never in a public URL. | `TrackConfiguration.cs:18`, `NormalizeReleaseTrack.cs:20` |
|
||
| `TrackEntity.EntryKey` | `string` | **App-minted at creation** — `Guid.NewGuid().ToString()` | **Public.** The track-detail route is `/track/{EntryKey}`; the streaming/waveform/by-key endpoints are all `EntryKey`-addressed. | `TrackContentService.cs:55`, `TrackConfiguration.cs:23` |
|
||
|
||
So tracks never put their sequential int in front of a listener. The opaque GUID string *is* the
|
||
public handle; the int PK is an implementation detail behind the ApiKey wall. **"Similar to how
|
||
tracks are keyed" therefore reads most faithfully as: give releases their own opaque app-minted GUID
|
||
string handle and address the public site by it — not as "retype the PK."** (Note one divergence:
|
||
a track's `EntryKey` doubles as the FileDatabase vault entry id, so it has a second job. A release
|
||
has no vault entry, so its `EntryKey` is purely an identifier. Daniel's call (2026-06-16) is to use
|
||
the **same member name (`EntryKey`) and type (`string`)** anyway, for naming consistency — see §3e.4.)
|
||
|
||
### 3e.2 The framework constraint — why a true PK swap is not a local change
|
||
|
||
The release PK is **not defined in this repo.** `ReleaseEntity : BaseEntity, IEntity` inherits `Id`
|
||
from `Cerebellum.BlazorBlocks.Models` (NuGet package, version 10.3.30 — ships as a compiled
|
||
`Models.dll`, no source). The same package supplies `Repository<DeepDrftContext, ReleaseEntity>` and
|
||
`Manager<…>`, whose CRUD surface (`GetByIdAsync`, `Delete`, etc.) is typed on the base `Id`. Three
|
||
hard signals that the framework hardwires `Id` to `long`:
|
||
|
||
1. **`TrackManager.cs:307`** — comment: *"Delete(long) → Result is inherited from `Manager<>` and
|
||
satisfies `ITrackService.Delete`."* The inherited delete takes `long`.
|
||
2. **`ReleaseManager.GetByIdAsync(long id)`** and **`SetSessionHeroImageAsync(long releaseId)`** etc.
|
||
are all typed `long` (`ReleaseManager.cs:81/97/110/130`).
|
||
3. **The migration** creates `id` as `bigint` / `IdentityByDefaultColumn` (`NormalizeReleaseTrack.cs:20`).
|
||
|
||
Unless the package exposes a *generic-keyed* base (e.g. `BaseEntity<TKey>` / `Repository<…, TKey>`)
|
||
— which the consuming code shows no sign of, and which would itself be a breaking generic-arity change
|
||
across `TrackEntity`, `SessionMetadata`, `MixMetadata`, and every `Manager<>`/`Repository<>` derived
|
||
type — **changing `ReleaseEntity.Id` to `Guid` is a framework fork, not an entity edit.** This is the
|
||
load-bearing reason the recommendation is the additive GUID *column*, not a PK retype. It is also
|
||
exactly why tracks solved the transparency problem with `EntryKey` rather than by retyping their own PK.
|
||
|
||
> **One thing to verify before finalizing the wave (staff-engineer, at implementation):** decompile or
|
||
> inspect `Cerebellum.BlazorBlocks.Models` 10.3.30 to confirm `BaseEntity.Id`/`BaseModel.Id` are
|
||
> non-generic `long`. If the package *does* offer a generic key, the PK-swap alternative (§3e.6)
|
||
> becomes viable and the decision in §3e.5(1) reopens. The recommendation assumes the non-generic case
|
||
> the consuming code strongly implies.
|
||
|
||
### 3e.3 The cross-stack surface map (every place a release id appears)
|
||
|
||
What the public-facing id touches today, and what each becomes under the **resolved additive
|
||
`EntryKey` (GUID-string) column** approach. "→ `EntryKey`" means "switch from the int `Id` to the new
|
||
app-level GUID-string handle"; "unchanged" means it stays on the internal int PK behind the ApiKey
|
||
wall.
|
||
|
||
| Layer | Site | Today | Resolved (additive `EntryKey`, track-pattern) |
|
||
|---|---|---|---|
|
||
| Entity | `ReleaseEntity` | `Id : long` (PK) | **add** `EntryKey : string` (`required`, app-minted, unique index), mirroring `TrackEntity.EntryKey`; `Id` **unchanged** (DB-only, unused by app) |
|
||
| EF config | `ReleaseConfiguration` | `id` PK + columns | **add** `entry_key` column (snake_case, like `TrackConfiguration`) + unique index; PK unchanged (`ReleaseConfiguration.cs:9-93`) |
|
||
| Satellites | `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId` | `long` FK to release PK | **unchanged** — internal FK stays on the int PK (1:1 join is server-side, never exposed) |
|
||
| Track FK | `TrackEntity.ReleaseId` | `long?` FK | **unchanged** — internal join (`TrackConfiguration.cs:42-50`) |
|
||
| DTO | `ReleaseDto` | `Id : long` (from `BaseModel`) | **add** `EntryKey : string`; `Id` still present but not used in public links |
|
||
| Converter | `TrackConverter.Convert(ReleaseEntity/ReleaseDto)` | maps `Id` | **add** `EntryKey` to both maps (`TrackConverter.cs:19-67`) |
|
||
| Detail routes | `/cuts/{id:long}`, `/sessions/{id:long}`, `/mixes/{id:long}`, `/tracks/{Id:long}` redirect | `:long` route constraints | **`{EntryKey}` string route params** (matching `/track/{EntryKey}`); pages load by `EntryKey` |
|
||
| Route resolver | `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` + `(ReleaseDto)` overload | takes `long id` | **takes `string entryKey`** (overload reads `release.EntryKey`) — landed in 11.B, this wave re-types it (`ReleaseRoutes.cs:21-29`) |
|
||
| Share | `SharePopover.ReleaseId` (11.E, `long?`) | `long?` | **`string?` `EntryKey`** — 11.E just added it; this wave re-types it |
|
||
| Data service | `IReleaseDataService.GetById(long)` + the `releaseId`-filtered track page call | `long` | a **`GetByEntryKey(string)`** read path; the public track page filters by release `EntryKey` (or resolves `EntryKey`→int server-side) |
|
||
| **Public** API | `GET api/release/{id:long}`, `…/{id:long}/mix/waveform`, the `releaseId` query on `GET api/track/page` | `:long` route / `long?` query | **`{entryKey}` string route / `string?` query** — public reads address by `EntryKey` (`ReleaseController.cs:73/111/134/165`, `TrackController` `releaseId`) |
|
||
| **CMS/ApiKey** API | `DELETE api/track/release/{id:long}`, `POST …/mix/waveform`, `…/session/hero-image` | `:long` | **judgment call (§3e.5(3))** — these sit behind the ApiKey wall and are not the transparency target. Recommend leaving them on the int PK to keep the blast radius at the public surface; the CMS already holds the full `ReleaseDto`. |
|
||
| JSON | `ReleaseDto` serialization | `Id` (number) | `EntryKey` serializes as a string — case-insensitive client deserialization already configured; verify the client reads `EntryKey` |
|
||
| Rendered/parsed | Cut/Session/Mix detail page id parse; player-bar resolver; Archive/Cuts card hrefs | parse/compare `long` | carry/compare the `EntryKey` string at the public surface (no parse needed — string in, string out, like track routes) |
|
||
|
||
**The honest scope line:** the `EntryKey` handle reaches *every public addressing site* (routes,
|
||
resolver, share, the public read path, the public-facing API params) and **stops at the ApiKey wall**
|
||
— the internal FKs (`TrackEntity.ReleaseId`, the satellite `ReleaseId`s), the `long` PK, and the
|
||
CMS-only endpoints stay on the int. This is the minimal surface that satisfies "the id Daniel sees is
|
||
no longer transparent" while honoring the framework constraint and matching the track model exactly.
|
||
|
||
### 3e.4 EntryKey generation site — app-side, matching tracks
|
||
|
||
Track `EntryKey` is minted **app-side** (`Guid.NewGuid().ToString()` in `TrackContentService`), not
|
||
by a DB default. Two reasons to follow that here rather than a Postgres `gen_random_uuid()` server
|
||
default: (1) **consistency** — "just like tracks" includes *where the value is born*; (2) the release
|
||
is created in app code (the `FindOrCreateRelease` path the upload flow runs), so the mint site is
|
||
already in hand. Set `EntryKey = Guid.NewGuid().ToString()` at release creation in that path — the
|
||
identical call tracks make. **No DB default, no `HasDefaultValueSql`** — the column is app-populated
|
||
and carries a unique index.
|
||
|
||
**Naming/type — RESOLVED (Daniel, 2026-06-16): `EntryKey`, `string`.** The earlier draft proposed a
|
||
distinct `PublicId : Guid` column on the reasoning that a release has no vault entry, so a native
|
||
`uuid` column would index better. Daniel overrode that in favor of **genuine consistency with tracks**:
|
||
use the **same member name (`EntryKey`) and the same type (`string`)**, even though the release GUID
|
||
has no vault job. Verified against live source — `TrackEntity.EntryKey` is `required string`
|
||
(`DeepDrftModels/Entities/TrackEntity.cs:14`), column `entry_key` (snake_case, max 100, configured in
|
||
`TrackConfiguration`), minted as `Guid.NewGuid().ToString()`. `ReleaseEntity.EntryKey` mirrors all of
|
||
this: `required string`, `entry_key` column, app-minted GUID string, unique index. The minor
|
||
indexing edge of a native `uuid` column is not worth diverging the two entities' identifier model.
|
||
|
||
**Existing-data backfill — RESOLVED:** the migration mints a GUID-string `EntryKey` for every existing
|
||
release row at migration time (the same `Guid.NewGuid().ToString()` shape), then marks the column
|
||
non-null + unique. Not a dev reset. See §3e.5(2).
|
||
|
||
### 3e.5 The four decisions (two now RESOLVED by Daniel 2026-06-16; two remain open)
|
||
|
||
1. **PK strategy — additive GUID-string column vs. true PK retype. THE GATING DECISION — RESOLVED
|
||
(additive `EntryKey`, track-pattern).** Daniel chose the **additive app-level `EntryKey` (`string`)
|
||
column** (§3e.1–3e.2, §3e.4): it matches the track model exactly, avoids a framework fork, keeps the
|
||
migration trivial (add a column + backfill GUID strings, no FK rewrites), and the `long` int PK
|
||
stays DB-only/unused by the app. Daniel's words: *"long at the DB level with an app-level guid
|
||
`EntryKey` for the releases just like tracks. PK is not used by the app. … Migrate the existing
|
||
data to provide the entry key at migration time."* The true PK retype (§3e.6) is the literal reading
|
||
of the brief but costs a framework fork (or confirmation the package is generic-keyed) **and** a full
|
||
FK rewrite across two satellites + the track table — **declined** (recorded in §3e.6 per file
|
||
convention). Nothing about this gating call remains open.
|
||
|
||
2. **Existing-data conversion strategy — RESOLVED (in-migration backfill at migration time).** Daniel:
|
||
*"Migrate the existing data to provide the entry key at migration time."* The migration mints a
|
||
GUID-string `EntryKey` for every existing release row (mirroring the track mint, `Guid.NewGuid()
|
||
.ToString()`), then marks the column non-null + unique — **not** a dev-DB reset. (Mechanically this
|
||
is the in-migration backfill the earlier draft recommended as option (a): the schema carries real
|
||
migration history — eight applied migrations, a normalization data-migration that back-filled
|
||
releases from tracks, a unique-title-artist constraint with a conflict-recovery path — i.e. a
|
||
database with actual content. A Postgres `gen_random_uuid()` backfill cast to text, or an app-pass,
|
||
both produce the GUID-string shape; staff-engineer picks the mechanism, but the *decision* — backfill
|
||
at migration time, no reset — is settled.)
|
||
|
||
3. **Public URL exposure — raw GUID vs. slug/short-id.** Daniel's stated motivation is "int IDs too
|
||
transparent," and a raw GUID in the URL (`/cuts/9f8a…`) fully satisfies that — it leaks neither
|
||
count nor order. A slug (`/cuts/midnight-drift`) or short-id would be prettier but is a **separate
|
||
feature** (needs a slug column, collision handling, and a slugify step) and is *not* what the brief
|
||
asked for. *Recommend the raw GUID in the URL for this wave* (it is the direct, track-consistent
|
||
answer — `/track/{EntryKey}` is already a raw GUID string and nobody has asked to prettify it), and
|
||
**flag the slug as an adjacent future option** if Daniel later wants shareable-pretty URLs. Naming
|
||
it so the door stays open without widening this wave.
|
||
|
||
4. **Migration apply is Daniel-gated** (consistent with 11.G). Authoring the migration (add `entry_key`,
|
||
backfill GUID strings, unique index, non-null) is **in scope**; running `dotnet ef database update` is **not** —
|
||
it applies on Daniel's go, exactly as `20260616035252_AddReleaseDescription` (11.G) is staged but
|
||
not applied. **Coordinate the two migrations:** 11.G's `AddReleaseDescription` is authored-not-applied
|
||
and also touches the `release` table; whichever applies second must be generated *after* the first so
|
||
the EF model snapshot is linear (no divergent-snapshot conflict). Recommend authoring 11.H's migration
|
||
**on top of** 11.G's (i.e. after 11.G is in the tree), and applying them in author order. Surface
|
||
this ordering to Daniel as part of the migration go-ahead.
|
||
|
||
### 3e.6 The alternative — true PK retype (considered and DECLINED, Daniel 2026-06-16)
|
||
|
||
For completeness, the literal "change the PK to GUID" path, so the rejection is on record (Daniel
|
||
firmly declined this 2026-06-16 in favor of the additive `EntryKey` column — "PK is not used by the
|
||
app"):
|
||
|
||
- `ReleaseEntity.Id` becomes `Guid` (requires a generic-keyed framework base, or a fork of
|
||
`Cerebellum.BlazorBlocks.Models`).
|
||
- `TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId` all become `Guid` /
|
||
`Guid?` FKs; the migration mints a GUID per release and **rewrites every FK value** to match
|
||
(a multi-table data migration, ordered: mint release GUIDs → rewrite track FKs → rewrite satellite
|
||
FKs → swap PK).
|
||
- Every `Manager<>`/`Repository<>` `long`-typed CRUD call across tracks *and* releases shifts to `Guid`.
|
||
|
||
*Why declined:* it is a framework fork (or a confirmation-gated generic-key assumption) plus a
|
||
full FK-rewrite migration, for an outcome the additive `EntryKey` column achieves at the public surface
|
||
with a single new column and a backfill — and the additive approach is *what tracks already do*. The PK
|
||
retype buys nothing the column doesn't, at materially higher risk. **Daniel chose §3e.1 (additive
|
||
`EntryKey` column) on 2026-06-16; §3e.6 is recorded as the considered-and-declined literal reading** —
|
||
kept visible per file convention in case the PK-retype question ever resurfaces.
|
||
|
||
### 3e.7 Sequencing — why 11.H must follow 11.B–11.E
|
||
|
||
This wave **sweeps the very files 11.B–11.E create or edit**, so authoring it concurrently would
|
||
guarantee conflicts:
|
||
|
||
- **11.B** created `ReleaseRoutes.DetailHref(long id, …)` and the `/tracks/{Id:long}` redirect page —
|
||
11.H re-types both to the `string` `EntryKey` handle (`ReleaseRoutes.cs`, `TrackRedirect.razor`).
|
||
*(11.B is landed 2026-06-16, so this dependency is already satisfied.)*
|
||
- **11.C** folds Archive + Cuts cards into `ReleaseGallery` with the `HrefResolver` that calls
|
||
`ReleaseRoutes.DetailHref` — 11.H changes the id type flowing through that resolver. Sweeping the
|
||
card markup while 11.C is mid-flight would collide. **11.C must land first.**
|
||
- **11.D** edits `ArchiveView` (filters → URL); 11.H touches the same Archive card hrefs. **Sequence
|
||
after 11.D** (or fold carefully — but cleaner to follow).
|
||
- **11.E** just added `SharePopover.ReleaseId` as `long?` — 11.H re-types it to `string?` (the
|
||
`EntryKey` handle). **Must follow 11.E** or the type churns twice.
|
||
|
||
So the dependency is: **11.H follows 11.B (done), 11.C, 11.D, and 11.E.** It is the *last* public-site
|
||
wave of the phase by construction — it re-types the addressing surface that every prior wave builds on.
|
||
It is **independent of 11.G** except for the migration-ordering coordination in §3e.5(4) (both touch
|
||
the `release` table / EF snapshot). 11.G is authored-not-applied; author 11.H's migration after it.
|
||
|
||
### 3e.8 Observable acceptance criteria
|
||
|
||
- A release detail URL is `/cuts/{guid}` (e.g. `/cuts/9f8a3c2e-…`) — **no sequential integer appears
|
||
in any public release URL**; `/cuts/1` no longer resolves a release.
|
||
- The player-bar release-title click, Archive cards, and Cuts cards all navigate to the GUID URL
|
||
(via the re-typed `ReleaseRoutes.DetailHref`).
|
||
- `GET api/release/{entryKey}` returns the release; the legacy `GET api/release/{int}` no longer serves
|
||
the public read path (CMS/ApiKey endpoints' id type per decision 3e.5(3)).
|
||
- Every existing release row has a non-null, unique `EntryKey` (GUID string) after the (Daniel-applied)
|
||
migration — backfilled at migration time.
|
||
- Release-level Share (11.E) copies a GUID URL.
|
||
- Internal joins are unaffected: tracks still resolve to their release, satellites still 1:1-join,
|
||
the CMS still edits releases — none of which surfaces an `EntryKey` to a listener.
|
||
- New releases created via the upload/`FindOrCreateRelease` path receive a freshly minted `EntryKey`
|
||
(`Guid.NewGuid().ToString()`, the same call tracks make).
|
||
- `ReleaseEntity.EntryKey` is `required string` and `ReleaseDto.EntryKey` is `string`, mirroring
|
||
`TrackEntity.EntryKey` exactly (verified `required string` at `TrackEntity.cs:14`); the `long` PK is
|
||
unchanged and unused by the app.
|
||
|
||
### 3e.9 Wave placement — its own wave (11.H), last on the public-site critical path
|
||
|
||
11.H is a **standalone wave**: it is a cross-cutting re-type of the public release-addressing surface
|
||
plus a Daniel-gated migration, sharing files with (and therefore gated behind) 11.B–11.E. It is not a
|
||
graft onto another wave — it touches the entity, the DTO, the converter, the routes, the resolver, the
|
||
share control, the public API params, and the public read path in one coherent sweep. Like 11.G it
|
||
carries a Daniel-gated migration; unlike 11.G it is **not** free-floating — its file overlap makes it
|
||
the terminal public-site wave. See §6 for the wave entry and the updated dependency shape.
|
||
|
||
---
|
||
|
||
## 4. Requirement 3 — full stack retirement + shared release-card normalization
|
||
|
||
**DECIDED (2026-06-15):** retire the **whole** track-cardinal stack (not just the `?album` branch),
|
||
*and* normalize release-card rendering into shared component(s) consumed across `/archive`, `/cuts`,
|
||
`/sessions`, `/mixes`. This is two moves — a **reduction** (delete dead surface) and a
|
||
**normalization** (collapse duplicated card markup into one component). The normalization is the
|
||
heart of 11.C; the reduction is its precondition cleanup.
|
||
|
||
### 4.1 The reduction — retire the track-cardinal stack (DECIDED, no longer a Daniel question)
|
||
|
||
Daniel confirmed the full retirement. The surface that goes:
|
||
|
||
| Surface | Route | Why it is now dead |
|
||
|---|---|---|
|
||
| `TracksView` | `/tracks` (+ `?album`/`?genre`/`?q`) | Archive subsumes browse/search; `/cuts/{id}` subsumes album view; nav-demoted since Phase 9 §8.I. |
|
||
| `TrackDetail` | `/track/{EntryKey}` | Loses its last inbound link once §2 repoints the player-bar title to the release. |
|
||
| `TrackCard` | — | Only consumer is `TracksGallery`, which only `TracksView` uses. |
|
||
| `TracksGallery` | — | Only consumer is `TracksView`. |
|
||
| `GalleryViewMode` | — | Only the `TracksView` filter-mode enum. |
|
||
|
||
**Ordering of the retirement (the dependency chain):**
|
||
|
||
1. §3 — `/cuts/{id}` exists.
|
||
2. §2 — `ReleaseRoutes` resolves Cut → `/cuts/{id}`; the resolver repoints the player-bar title to
|
||
the release (removing `TrackDetail`'s inbound link) and repoints `AlbumsView` + Archive Cut cards
|
||
off `/tracks?album`.
|
||
3. §4.1 — with no inbound links remaining, delete the whole stack above.
|
||
|
||
So the retirement is **gated on §2 and §3 landing first** — you cannot delete `TrackDetail` while the
|
||
player bar still links to it, and you cannot delete the `?album` branch while `AlbumsView`/Archive
|
||
still point at it. This sequencing is honored in §6.
|
||
|
||
> **One inbound-link audit before deletion (verify, don't assume):** grep for every `href`/`NavigateTo`
|
||
> targeting `/tracks` and `/track/`. The known ones are the player-bar title (`TrackMetaLabel`),
|
||
> `AlbumsView.OpenAlbum`, and `ArchiveView.DetailHref` — all repointed by §2. The `/albums → /cuts`
|
||
> redirect (`AlbumsRedirect`) stays. The CMS `/tracks/{id}/edit` family is a **different route tree**
|
||
> (`DeepDrftManager`) and is untouched — do not confuse the public `/tracks` gallery with the CMS
|
||
> track-edit routes.
|
||
|
||
### 4.2 The normalization — one shared release-card component (the heart of 11.C)
|
||
|
||
**Recommendation: make `ReleaseGallery` the single release-card grid, and give its card a per-card
|
||
href resolver so it can serve Archive's per-medium routing. Repoint `/archive` and `/cuts` onto it;
|
||
delete the inline `archive-release-card` and `album-card` markup.** This is the *One source, multiple
|
||
views* discipline (memory) applied to card rendering.
|
||
|
||
#### 4.2.1 What's redundant today (audited against live source, 2026-06-15)
|
||
|
||
| Surface | Card rendering | Verdict |
|
||
|---|---|---|
|
||
| `/sessions`, `/mixes` (`SessionsView`/`MixesView`) | Compose **`ReleaseGallery`** with a fixed `DetailRoute`. | **Canonical.** Already shared. |
|
||
| `/archive` (`ArchiveView`) | Inline `archive-release-card` markup — cover (+`--fallback`), title, artist. **Byte-for-byte the same structure as `ReleaseGallery`'s `release-card`**, only the CSS class prefix and the per-card `DetailHref` differ. | **Redundant copy.** Fold into `ReleaseGallery`. |
|
||
| `/cuts` (`AlbumsView`) | Inline `album-card` markup, card click → `OpenAlbum` → `/tracks?album`. | **Redundant copy + wrong target.** Fold into `ReleaseGallery`, repoint to `/cuts/{id}`. |
|
||
|
||
Three near-identical card implementations across four browse surfaces. The structure is identical
|
||
(cover with `--fallback`, body with truncated title + artist); the only real divergence is **how a
|
||
card computes its href**: Sessions/Mixes use a fixed route segment; Archive resolves per-medium.
|
||
|
||
#### 4.2.2 The shared component contract
|
||
|
||
`ReleaseGallery` already owns the grid, skeleton-loading, empty-state, and card markup. Generalize its
|
||
**href computation** so one component serves both the fixed-route case and the per-medium case:
|
||
|
||
- **Today:** `[Parameter] string DetailRoute` → card links `/{DetailRoute}/{id}`.
|
||
- **Add:** an optional per-card href resolver, `[Parameter] Func<ReleaseDto, string>? HrefResolver`.
|
||
When supplied, the card links `HrefResolver(release)`; otherwise it falls back to the
|
||
`DetailRoute`-based href (Sessions/Mixes unchanged). Archive passes
|
||
`HrefResolver="@ReleaseRoutes.DetailHref"` (the §2 resolver) — so each Archive card routes by its
|
||
own medium through the **same one table**.
|
||
- **Optionally fold the medium-route default into the resolver itself:** since `ReleaseRoutes` already
|
||
knows medium→route, Sessions/Mixes could *also* drop `DetailRoute` and just pass the resolver. But
|
||
keeping `DetailRoute` as the simple default avoids churning two working pages — **recommend adding
|
||
`HrefResolver` as the new path, leaving `DetailRoute` as the back-compat default**, and migrating
|
||
Sessions/Mixes only if it's free.
|
||
|
||
After this: `ReleaseGallery` is the one card grid. `archive-release-card` and `album-card` markup
|
||
(and their CSS) are deleted; `ArchiveView` keeps **only** its search/filter chrome above the grid
|
||
(the chrome is genuinely Archive-specific — it does not belong in the shared card component).
|
||
|
||
> **CSS consolidation:** the inline copies carry parallel CSS (`archive-release-*`, `album-card-*`).
|
||
> When the markup folds into `ReleaseGallery`, those rules collapse into the `release-card-*` rules.
|
||
> Verify the visual treatments match before deleting (cover sizing, fallback styling, truncation) —
|
||
> if Archive/Cuts cards were tuned differently, fold the differences into `ReleaseGallery` via a
|
||
> modifier class, not by keeping the duplicate.
|
||
|
||
#### 4.2.3 The Cuts-detail track row — same component, or different?
|
||
|
||
The brief asks whether the shared card extends to "the Cuts detail track list where applicable." The
|
||
Cut detail's track *rows* (§3.1 — `1. ▶ Track One … 3:42`) are a **different shape** from a release
|
||
*card* (a horizontal row with ordinal + play + duration, not a cover-forward card). **Recommend a
|
||
separate small `TrackRow` component** for the Cut track list rather than forcing it into
|
||
`ReleaseGallery` — they share nothing structurally. If a `TrackRow` is extracted, it is the natural
|
||
shared row for any future track-list surface, but it is *not* the release-card component. Flag this so
|
||
the normalization doesn't over-reach into forcing two unlike things into one component.
|
||
|
||
### 4.3 Residual redundancy (opportunistic, surface don't sprawl)
|
||
|
||
1. **`ArchiveView.MediumLabel`** — a medium→label lookup. Check whether it duplicates a CMS
|
||
`MediumTypeLabels` or the Archive's own medium-chip labels; if the public side has two
|
||
medium-label lookups, **consolidate to one**. **Medium confidence; opportunistic.**
|
||
2. **`GenresView` (`/genres`)** — already nav-demoted (§8.I); Archive has genre filtering. Likely
|
||
retire-able, but **out of this phase's stated scope** — flag as adjacent, low urgency. **Not in
|
||
Phase 11** unless Daniel pulls it in.
|
||
|
||
> Land the full stack retirement (§4.1) and the card normalization (§4.2) — both decided. Treat 4.3
|
||
> as tidy-ups: do them if they fall out of the normalization for free, surface `/genres` as adjacent.
|
||
|
||
---
|
||
|
||
## 5. Requirement 4 — Archive filters in the URL
|
||
|
||
**This is a URL-binding pass over the existing `ArchiveView`, borrowing the `TracksView` pattern
|
||
verbatim.** No new browser, no new data path — the filter state already drives `LoadReleases`; the
|
||
change is making that state **enter and leave via the query string**.
|
||
|
||
### 5.1 Target URL scheme
|
||
|
||
```
|
||
/archive?q={search}&medium={cut|session|mix}&genre={genre}
|
||
```
|
||
|
||
- All three params optional; omitting one means "no filter on that axis" (matches the current
|
||
null-means-all semantics).
|
||
- `medium` uses the same lowercase enum token the data service already speaks
|
||
(`Medium.ToString().ToLowerInvariant()`), parsed back with `Enum.TryParse(ignoreCase:true)` +
|
||
`Enum.IsDefined` — the exact posture `BatchUpload` (§8.E) and the API already use.
|
||
- Plain `/archive` (no params) = the unfiltered first page, which is the bridged/prerendered state.
|
||
|
||
### 5.2 The binding mechanics (borrow `TracksView`)
|
||
|
||
`TracksView.razor.cs` is the template:
|
||
|
||
- **In (URL → state):** add `[SupplyParameterFromQuery]` for `q`, `medium`, `genre`. Seed
|
||
`SearchText` / `_selectedMedium` / `_selectedGenre` from them in `OnInitializedAsync` **before**
|
||
the restore/fetch decision (so a direct nav to a filtered URL fetches filtered, and the bridge
|
||
restore is skipped when a filter is active — `ArchiveView` already has `HasActiveFilter` gating
|
||
exactly this).
|
||
- **Out (state → URL):** each of `OnSearchInput` / `OnMediumSelected` / `OnGenreSelected` calls
|
||
`Navigation.NavigateTo($"/archive?{composed query}")` **instead of** (or before) calling
|
||
`LoadReleases` directly. The query-param change drives the re-fetch.
|
||
|
||
### 5.3 The one real subtlety — same-route query change does not re-run `OnInitialized`
|
||
|
||
Blazor reuses the component on a same-route query-string change and fires `OnParametersSet`, **not**
|
||
`OnInitializedAsync` (the `TracksView.ClearFilter` comment, lines 117–120, documents exactly this
|
||
trap). So the filter→fetch reaction must live where it sees the change:
|
||
|
||
- **Option A (history-driven):** filter handlers only `NavigateTo` the new URL; move the
|
||
state-seeding + `LoadReleases` into `OnParametersSet`/`OnParametersSetAsync` keyed off the query
|
||
params. Cleanest — the URL is the single source of truth; back/forward "just works" because each
|
||
nav re-runs the same seed-and-fetch. **Recommended.**
|
||
- **Option B (dual-write):** handlers both `NavigateTo` *and* `LoadReleases` directly. Simpler diff
|
||
but the URL and the fetch are two writes that can drift, and back/forward needs separate handling.
|
||
|
||
**Recommend Option A.** It makes the URL the source of truth (which is the whole point of the
|
||
requirement) and gets shareable links + back/forward correctness as a structural consequence rather
|
||
than as bolted-on handling. Guard against the debounce/nav interplay: the search field debounces
|
||
(400ms) before firing; ensure a debounced search nav doesn't fight a rapid medium-chip nav (the
|
||
`OnParametersSet` reaction should be idempotent on identical param sets — mirror the
|
||
`_loadedEntryKey` guard idiom).
|
||
|
||
### 5.4 Persistence interaction
|
||
|
||
The bridged unfiltered first page (`PersistKey = "archive-releases"`) must keep restoring **only**
|
||
when no filter is active — `ArchiveView` already gates persist + restore on `HasActiveFilter`. The
|
||
URL-binding pass must preserve that gate: a `/archive?medium=mix` direct load must **fetch**, not
|
||
restore the unfiltered bridge. The existing `HasActiveFilter` check already expresses this; the
|
||
seed-from-URL step just has to run before the restore decision (as §5.2 specifies).
|
||
|
||
---
|
||
|
||
## 6. Wave decomposition
|
||
|
||
Sequenced so the structural chain (Cuts page → resolver → repoint → retire/normalize) is honored,
|
||
and the genuinely independent tracks (Archive URL; the queue model; the Description schema slice) can
|
||
run in parallel. Nine commitments, **eight waves**. Two work items can start cold on day one: the
|
||
queue (11.F, gate for the Cuts "play album" affordance) and the Description schema slice (11.G, gated
|
||
only by Daniel's migration go-ahead). One wave is **terminal by construction**: 11.H (release GUID
|
||
identifiers) re-types the public addressing surface that 11.B–11.E all build on, so it follows them.
|
||
|
||
```
|
||
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||
│ 11.A /cuts/{id} page │ │ 11.D Archive filters→URL │ │ 11.F Queue model │
|
||
│ + CutDetailViewModel │ │ (TracksView-pattern) │ │ (IQueueService above the │
|
||
│ + cover theme border │ │ INDEPENDENT │ │ single-slot player) │
|
||
│ + ordered track list │ └──────────────────────────┘ │ + player TrackEnded hook │
|
||
│ (verify public read │ │ + player-bar skip controls│
|
||
│ projects+sorts │ │ INDEPENDENT (cold start) │
|
||
│ TrackNumber — §3a) │ └────────────┬─────────────┘
|
||
└──────────┬───────────────┘ │
|
||
│ (11.A header Play & row Play
|
||
▼ consume 11.F's PlayRelease;
|
||
┌──────────────────────────┐ degrade to single-track if 11.F
|
||
│ 11.B ReleaseRoutes │◄── needs 11.A (Cut→/cuts/id) later — §3.4 seam)
|
||
│ resolver + repoint │
|
||
│ (player-bar title→release,│
|
||
│ Archive + AlbumsView │ ┌──────────────────────────┐
|
||
│ cards) + thin /tracks/id │ │ 11.E Release-level Share │
|
||
│ redirect │ │ (SharePopover release │
|
||
└──────────┬───────────────┘ │ mode, copy ReleaseRoutes │
|
||
│ │ URL) — needs 11.B resolver│
|
||
│◄────────────────────┤ + a release detail to │
|
||
▼ │ share (11.A/Session/Mix) │
|
||
┌──────────────────────────┐ └──────────────────────────┘
|
||
│ 11.C Retire + normalize │ ┌──────────────────────────┐
|
||
│ • delete track-cardinal │ │ 11.G Release Description │
|
||
│ stack (Tracks*/Track*) │ │ schema slice (col + EF │
|
||
│ • fold Archive+Cuts cards │ │ migration [Daniel-gated], │
|
||
│ into shared ReleaseGallery│ │ DTO/converter/write path, │
|
||
│ • consolidate medium-label│ │ CMS multiline input) │
|
||
└──────────────────────────┘ │ INDEPENDENT (cold start) │
|
||
│ render rides 11.A + a │
|
||
│ Session/Mix detail touch │
|
||
└──────────────────────────┘
|
||
```
|
||
|
||
- **11.A — `/cuts/{id}` album-detail page.** The page, `CutDetailViewModel`, cover theme border,
|
||
ordered track list (ordered by `TrackNumber` — §3a), per-row play, header Play, Share button.
|
||
**Ordinal is a verification, not a dependency:** §3a confirmed `TrackNumber` already exists and the
|
||
read likely already sorts on it; 11.A's one checklist item is *confirm the public read projects +
|
||
sorts `TrackNumber`* (one-line fix if not). **Depends only on existing `GetById` + `releaseId`-
|
||
filtered track page (both exist).** Header/row Play call into 11.F when present, else degrade to
|
||
single-track streaming (§3.4 seam). **Load-bearing prerequisite for 11.B's Cut resolution.**
|
||
- **11.B — `ReleaseRoutes` resolver + repoint.** Promote `ArchiveView.DetailHref` to a shared
|
||
`ReleaseRoutes.DetailHref`; Cut now resolves to `/cuts/{id}` (needs 11.A); repoint player-bar
|
||
title (→ release), Archive cards, `AlbumsView` cards; add the thin `/tracks/{id}` redirect page.
|
||
**Depends on 11.A.** Verify `TrackDto.Release` is always populated on the player bar (so the
|
||
resolver has a medium — §7 gap).
|
||
- **11.C — retire + normalize (the heart).** With §2 having removed every inbound link: **delete the
|
||
whole track-cardinal stack** (`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/
|
||
`GalleryViewMode` + `/tracks`, `/track/{EntryKey}` routes — §4.1) **and** fold the Archive + Cuts
|
||
inline card markup into the shared `ReleaseGallery` via the new `HrefResolver` (§4.2); consolidate
|
||
the medium-label lookup (§4.3). **Depends on 11.B.** (The Cut track-row is a separate small
|
||
`TrackRow`, not `ReleaseGallery` — §4.2.3.)
|
||
- **11.D — Archive filters in the URL.** `/archive?q=&medium=&genre=`, history-driven (§5). Fully
|
||
independent — touches only `ArchiveView`. **No dependency; free-floating.** *Note:* 11.D and 11.C
|
||
both touch `ArchiveView` — sequence them so they don't collide (do 11.D's URL-binding and 11.C's
|
||
card-fold in one `ArchiveView` pass, or land 11.D first and rebase 11.C onto it).
|
||
- **11.E — release-level Share.** `SharePopover` gains a release mode that copies
|
||
`ReleaseRoutes.DetailHref(release)` (§3b). **Depends on 11.B** (needs the resolver for the URL) and
|
||
on a release detail existing to share (11.A for Cuts; Session/Mix already exist). Wire the Cut
|
||
header Share (and optionally Session/Mix headers) to open the popover in release mode.
|
||
- **11.F — queue model.** `IQueueService` orchestrating above the single-slot player; the one new
|
||
player-side hook (`TrackEnded`); player-bar skip controls (§3c). **Independent — can start cold on
|
||
day one**, in parallel with 11.A/11.D. It is the **gate for the Cuts "play album" affordance**:
|
||
11.A's header Play calls `QueueService.PlayRelease(tracks)` once 11.F lands (degrading to
|
||
single-track before then). **Preload (§1.3b) is OUT of this wave** — design the seam, defer the
|
||
feature (§3c.5).
|
||
- **11.G — release Description schema slice.** New `ReleaseEntity.Description` column + EF migration
|
||
(**Daniel-gated apply**), `ReleaseDto` mirror, `TrackConverter` round-trip, write-path plumbing
|
||
(`UpdateTrackMetadataRequest` + upload form + the unified services — threaded wherever `Genre` is),
|
||
and the CMS `AlbumHeaderFields` multiline input (§3d). **Independent — can start cold on day one**,
|
||
in parallel with everything; the only gate is Daniel's migration go-ahead. The **detail-page render
|
||
is NOT in this wave** — the Cut text block rides 11.A; the Session/Mix text block is a small additive
|
||
touch to those existing pages. Both degrade cleanly (a null Description renders nothing), so the
|
||
render can land before or after 11.G applies (§3d.4–3d.5).
|
||
- **11.H — release GUID identifiers (terminal public-site wave).** Front the release `long` PK with an
|
||
app-minted GUID-string `EntryKey` column — the **same member name and type tracks use** (§3e.1, §3e.4);
|
||
make `EntryKey` the only release id the public site and public API expose. New `ReleaseEntity.EntryKey`
|
||
(`required string`, unique index, app-minted `Guid.NewGuid().ToString()` at `FindOrCreateRelease`,
|
||
mirroring `TrackEntity.EntryKey`) + EF migration that **backfills an `EntryKey` for every existing
|
||
release row at migration time** (**Daniel-gated apply**); `ReleaseDto.EntryKey`; `TrackConverter`
|
||
round-trip; **re-type the public addressing surface from `long` to the `EntryKey` handle**: detail
|
||
routes (`/cuts|sessions|mixes/{id}` → `{EntryKey}` string params, like `/track/{EntryKey}`), the
|
||
`/tracks/{id}` redirect, `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path
|
||
(`GetByEntryKey`, the `releaseId`-filtered track page), and the public release API params
|
||
(`GET api/release/{id}` + `releaseId` query). Internal FKs (track→release, satellite→release), the
|
||
`long` int PK (unused by the app), and the ApiKey-gated CMS endpoints **stay on the int** (§3e.3,
|
||
decision 3e.5(3)). **Depends on 11.B (landed), 11.C, 11.D, 11.E** — it sweeps the files those waves
|
||
create/edit, so it is the *last* public-site wave (§3e.7). **Gating decision RESOLVED (Daniel,
|
||
2026-06-16): additive `EntryKey` (track-pattern); `long` PK unused by app; backfill at migration time**
|
||
(§3e.5(1)–(2)). Migration ordered after 11.G's (§3e.5(4)). **Retyping the PK itself is DECLINED** —
|
||
that is a framework fork (§3e.2, §3e.6); the additive `EntryKey` column achieves the transparency goal
|
||
at the public surface, exactly as tracks do.
|
||
|
||
**Dependency shape:**
|
||
|
||
```
|
||
11.A ──► 11.B ──► 11.C ─┐
|
||
└────► 11.E ─┤
|
||
11.D (free-floating; coordinate with 11.C on ArchiveView) ─┤
|
||
├──► 11.H (re-types the public
|
||
11.F (free-floating cold start; 11.A's "play album" uses it)│ addressing surface that
|
||
11.G (free-floating cold start; Daniel-gated migration. │ 11.B–11.E build on; terminal
|
||
Render rides 11.A + a Session/Mix touch — degrades │ public-site wave. Migration
|
||
on null, so render & schema land in either order) │ ordered after 11.G's snapshot.)
|
||
─┘
|
||
```
|
||
|
||
**Critical path:** `11.A → 11.B → 11.C → 11.H`. **11.D, 11.E, 11.F, 11.G hang off the front** (11.E
|
||
after 11.B; 11.D, 11.F, 11.G fully parallel), but **11.H sits at the tail** — it depends on 11.B
|
||
(landed), 11.C, 11.D, and 11.E because it re-types the routes/resolver/share/cards those waves touch.
|
||
The "can start immediately" items are still **11.A**, **11.F**, and **11.G**; **11.H starts last**, once
|
||
the public addressing surface it rewrites has stopped moving. 11.G and 11.H both carry Daniel-gated
|
||
migrations against the `release` table — author 11.H's *after* 11.G's so the EF model snapshot stays
|
||
linear, and apply in author order (§3e.5(4)).
|
||
|
||
> **Honest dependency notes (the brief asked to keep these straight):**
|
||
> - **Ordinal does *not* gate a wave.** §3a showed `TrackNumber` already exists end-to-end; the
|
||
> "ordering" dependency is a one-line verification inside 11.A, not sequencing.
|
||
> - **Queue gates the "play album" affordance, not the Cut page.** 11.A ships with a degradable Play
|
||
> (single-track) if 11.F is not yet in; the affordance becomes "enqueue album" the moment 11.F
|
||
> lands, via the §3.4 handler swap. So 11.A and 11.F are parallel, with a clean late-binding seam.
|
||
> - **Shared cards parallel the stack retirement.** Folding Archive/Cuts cards into `ReleaseGallery`
|
||
> (§4.2) and deleting the track-cardinal stack (§4.1) are both in 11.C and both depend on 11.B's
|
||
> repoint — they are siblings, not sequential.
|
||
> - **Description schema (11.G) gates the Description *render*, not the Cut page.** 11.A ships its Cut
|
||
> page regardless; the Description block (on 11.A and on Session/Mix) renders nothing until 11.G's
|
||
> column carries data, then lights up with no rework. So 11.G is a cold-start free-floater whose only
|
||
> hard gate is Daniel's migration go-ahead — *not* a dependency of any other wave. Unlike commitment
|
||
> 5 (already-built, a verification), commitment 8 is a real migration; unlike the queue, it has no
|
||
> architecture question — it is a mechanical vertical that rides the existing `Genre` write channel.
|
||
|
||
---
|
||
|
||
## 7. Resolved decisions and remaining open questions
|
||
|
||
### 7.1 Resolved by Daniel (2026-06-15) — kept visible as *decided*, not deleted
|
||
|
||
1. **Player-bar title target (§2).** **DECIDED:** the title click resolves **release** detail via the
|
||
resolver, replacing the `/track/{EntryKey}` link. (Was OQ1.)
|
||
2. **Track ordinal (§3a).** **DECIDED:** explicit ordinal column, editable from the CMS — *and the
|
||
read confirms it already exists* (`TrackEntity.TrackNumber`, migration applied, CMS reorder live).
|
||
No new schema; verify the public read projects/sorts it. (Was OQ4 "do not assume into Phase 11";
|
||
Daniel reversed to in-scope, and it turned out already-built.)
|
||
3. **`/tracks` retirement scope (§4.1).** **DECIDED:** retire the **whole** track-cardinal stack
|
||
(`TracksView`/`TrackDetail`/`TrackCard`/`TracksGallery`/`GalleryViewMode` + routes), not just the
|
||
`?album` branch. (Was OQ5; the full-cut option chosen.)
|
||
4. **Release-level Share (§3b).** **DECIDED:** in scope. `SharePopover` gains a release-keyed mode.
|
||
(Was an adjacent gap; promoted.)
|
||
5. **Play-queue system (§3c).** **DECIDED:** in scope; absorbs the queue half of `PLAN.md §1.3`. The
|
||
Cuts "play album" affordance is its first consumer. (Was an adjacent gap; promoted.) Preload half
|
||
stays deferred.
|
||
6. **Release Description field (§3d).** **DECIDED:** in scope. A multiline base-`ReleaseEntity` field
|
||
(all media), edited from the CMS add/edit forms and rendered as a text block on every detail page.
|
||
Confirmed against live source — the column does **not** exist, so this carries a real EF migration
|
||
(Daniel-gated apply) + DTO/converter/write-path/CMS plumbing. Lands as schema slice 11.G; render
|
||
rides 11.A + a Session/Mix touch. (Focused addition, 2026-06-15.)
|
||
|
||
### 7.2 Still open (need Daniel — recommendations given)
|
||
|
||
1. **`/cuts/{id}` scaffold strategy (§3.2).** Compose `ReleaseDetailScaffold` with a generalized
|
||
`Header` slot (recommended) vs. bespoke page like `SessionDetail`. Sets whether the scaffold
|
||
contract changes. *Recommend the `Header`-slot generalization; bespoke as fallback.*
|
||
2. **Cut header affordance idiom (§3.2.2).** Keep the icon `PlayStateIcon`+`SharePopover` (consistent
|
||
with Session/Mix) vs. labeled **Play/Share buttons** (the literal spec wording). *Minor — flag.*
|
||
3. **Queue architecture (§3c).** Queue inside `IPlayerService` vs. a separate `IQueueService`
|
||
orchestrating above the single-slot player. *Strong steer: the separate `IQueueService` (§3c.1).*
|
||
**Final call is staff-engineer's at implementation** — the spec gives the steer, not the verdict.
|
||
4. **Release-share keeps "Embed player"? (§3b.3).** *Recommend copy-link-only in release mode across
|
||
all media; per-track embed stays where a track is the subject.* Trivial either way.
|
||
5. **`/genres` fate (§4.3).** Already nav-demoted; Archive has genre filtering. Retire `/genres` too?
|
||
*Out of stated scope — flag as adjacent, low urgency.* **Not in Phase 11** unless Daniel pulls it.
|
||
6. **Description render: plain text or markdown? (§3d.4).** *Recommend plain text with preserved line
|
||
breaks (`white-space: pre-line`) for v1 — no markdown dependency, matches "free-text field."* Trivial
|
||
to revisit if Daniel wants formatting. Also minor: the `HasMaxLength` ceiling for the column (§3d.3
|
||
step 1) — recommend generous (2000–4000) for paragraph prose; not a decision, just pick and note.
|
||
7. **Release id — additive GUID column vs. true PK retype (§3e.5(1)). THE GATING DECISION for 11.H —
|
||
RESOLVED (Daniel, 2026-06-16): additive `EntryKey` (track-pattern).** Releases get a new
|
||
`ReleaseEntity.EntryKey` (`required string`, app-minted GUID) — the *same member name and type tracks
|
||
use* — and the `long` PK stays DB-only, unused by the app. Daniel: *"long at the DB level with an
|
||
app-level guid `EntryKey` for the releases just like tracks. PK is not used by the app."* It avoids
|
||
forking the `Cerebellum.BlazorBlocks.Models` framework (whose `BaseEntity.Id` the consuming code shows
|
||
is hardwired `long`) and keeps the migration to a column + backfill rather than a full FK rewrite. The
|
||
literal "retype the PK to GUID" (§3e.6) is **declined** — recorded as considered-and-declined per file
|
||
convention (buys nothing the column doesn't, at framework-fork risk).
|
||
*(Staff-engineer should still confirm the package is non-generic-keyed at implementation — §3e.2 note —
|
||
though the decision stands regardless.)*
|
||
8. **Existing-data conversion for 11.H (§3e.5(2)) — RESOLVED (backfill at migration time).** Daniel:
|
||
*"Migrate the existing data to provide the entry key at migration time."* The migration mints a
|
||
GUID-string `EntryKey` for every existing release row (the `Guid.NewGuid().ToString()` shape tracks
|
||
use), then marks the column non-null + unique. **Not a dev-reset.** Staff-engineer picks the mechanism
|
||
(in-migration SQL backfill cast to text vs. an app pass); the *decision* — backfill at migration time —
|
||
is settled.
|
||
9. **Public URL form for 11.H (§3e.5(3)).** Raw GUID in the URL (`/cuts/{guid}`) vs. a slug/short-id.
|
||
*Recommend the raw GUID* — it directly satisfies "int IDs too transparent" and matches `/track/{EntryKey}`
|
||
(already a raw GUID string). A slug is a separate feature (slug column + collision handling); *flag as
|
||
an adjacent future option* if Daniel later wants pretty shareable URLs.
|
||
10. **CMS/ApiKey endpoint id type for 11.H (§3e.3, §3e.5(3)).** The ApiKey-gated release endpoints
|
||
(`DELETE api/track/release/{id}`, the mix-waveform / session-hero POSTs) are behind the auth wall and
|
||
are *not* the transparency target. *Recommend leaving them on the int PK* to keep 11.H's blast radius at
|
||
the public surface (the CMS already holds the full `ReleaseDto`). Flag — retyping them too is defensible
|
||
but widens the wave for no transparency gain.
|
||
|
||
### 7.3 Small things to get right (not decisions — implementer notes)
|
||
|
||
- **`TrackDto.Release` nullability on the player bar (11.B).** `TrackMetaLabel` guards `Track.Release?`
|
||
— if a track loads without its release populated, the resolver has no medium to route on. Verify the
|
||
streaming select path always carries `Release`. *Low risk; a verification line in 11.B.*
|
||
- **Public read projects/sorts `TrackNumber` (11.A).** The §3a verify step — confirm the public
|
||
`releaseId`-filtered track page sorts ascending by `TrackNumber` and carries it onto `TrackDto`.
|
||
- **Player `TrackEnded` hook (11.F).** The queue auto-advances on natural end-of-stream; verify the
|
||
player raises a track-ended event or add one (the single new player-side surface — §3c.2).
|
||
- **Year-only display (§3.1).** The Cut header shows just the year; `MixDetail`/`SessionDetail` show
|
||
"MMMM yyyy". Don't copy the month-year format into the Cut header.
|
||
- **Description is a base field, not medium-specific (11.G).** Put the CMS input in
|
||
`AlbumHeaderFields` (the base-fields block alongside Genre/Release Date), **not** in `MediumFields`.
|
||
Thread the write wherever `Genre` already threads (entity → DTO → converter → `UpdateTrackMetadataRequest`
|
||
→ upload form → unified services). No converter null-for-medium dance — it is uniform across media.
|
||
- **Description render degrades on null (11.G).** Most existing release rows will have `NULL`
|
||
Description until re-edited; the detail-page block must render nothing (not an empty heading) when
|
||
null. The Cut block lands in 11.A; the Session/Mix blocks are a small touch to those existing pages.
|
||
|
||
---
|
||
|
||
## 8. Why this is consistent with the system's grain
|
||
|
||
- **Release-cardinal everywhere.** Phase 9 + Wave 8 moved the public site to release-cardinal browse
|
||
(`/archive`) and per-medium detail. Phase 11 closes the one hole (Cuts had no detail page) and
|
||
makes the player-bar→detail path release-cardinal too. After this, the track-cardinal stack is
|
||
vestigial — which is *why* the reduction (§4) is available.
|
||
- **One source, multiple views.** The medium→route resolver (§2) is one table consumed by the player
|
||
bar, Archive, and Cuts cards — not three `DetailHref` switches. The shared `ReleaseGallery` (§4.2)
|
||
becomes the one release-card grid across all four browse surfaces, not three inline copies. The
|
||
queue (§3c) is one orchestrator the player bar and a future up-next panel both observe. The Cut
|
||
detail reuses the scaffold's invariant trio and the existing `GetById` + `releaseId`-filtered track
|
||
page — no new data path. (Memory: *One source, multiple views*.)
|
||
- **Design the seam, defer the feature.** Header Play binds to a single handler that swaps from
|
||
single-track to `QueueService.PlayRelease` with no page change (§3.4); preload subscribes to the
|
||
queue's "next is known" state rather than restructuring it (§3c.5). (Memory: *Design for
|
||
adaptability up front*.)
|
||
- **URL as source of truth (§5).** Borrowing the `TracksView` `[SupplyParameterFromQuery]` pattern
|
||
makes the Archive's filters addressable, which is the same shareable-link discipline the embed
|
||
player and `/tracks?album` deep links already established.
|
||
- **Extension, not modification.** The resolver and the `ReleaseDetailScaffold` `Header`/`BodyContent`
|
||
slots are additive; a future medium's detail page composes the same scaffold and the resolver gains
|
||
one entry — the Phase 9 Open/Closed discipline, unchanged.
|
||
- **Uniform release data lives on the base release (§3d).** The Description column lands on the base
|
||
`ReleaseEntity` because it is uniform across media — the same logic that puts `Genre`/`ReleaseDate`
|
||
there, and the explicit Phase 9 rule that satellites are for *medium-varying* data only
|
||
(`ReleaseType` being the one volume-justified exception). Description introduces no satellite, no
|
||
medium conditional, and rides the existing release-cardinal write channel — schema growth that
|
||
honors the spine rather than bending it.
|
||
|
||
---
|
||
|
||
## 9. Verified facts (read against live source 2026-06-15)
|
||
|
||
- **No `/tracks/{id}` route exists.** Track-cardinal detail is `/track/{EntryKey}` (`TrackDetail.razor`
|
||
line 1). Player-bar title links to `/track/{Track.EntryKey}` (`TrackMetaLabel.razor` line 9).
|
||
- **`/sessions/{id}` and `/mixes/{id}` exist and are mature** (`SessionDetail.razor`, `MixDetail.razor`,
|
||
both id-addressed, both inherit `ReleaseDetailBase`'s prerender bridge; `MixDetail` composes
|
||
`ReleaseDetailScaffold`, `SessionDetail` deliberately diverges).
|
||
- **Cuts have no single-release detail page.** `/cuts` is `AlbumsView` (medium-parameterized card
|
||
grid); cards open `/tracks?album={title}` (`AlbumsView.razor.cs` line 62). `/albums` → `/cuts`
|
||
redirect exists (`AlbumsRedirect.razor`).
|
||
- **`/archive` is release-cardinal, filters held in component fields not the URL.** `ArchiveView`
|
||
has `_selectedMedium`, `_selectedGenre`, `SearchText` as private state; no `[SupplyParameterFromQuery]`,
|
||
no `NavigateTo` on filter change (`ArchiveView.razor.cs`). It has a private `DetailHref` switch
|
||
(lines 121–126) routing Session→`/sessions/{id}`, Mix→`/mixes/{id}`, Cut→`/tracks?album={title}`.
|
||
- **`TracksView` already reads `?album=`/`?genre=`/`?q=` from the URL** via `[SupplyParameterFromQuery]`
|
||
(`TracksView.razor.cs` lines 21–23) — the pattern requirement 4 borrows. It documents the
|
||
same-route-query-change trap (`OnParametersSet` not `OnInitialized`, lines 117–120).
|
||
- **`ReleaseDetailScaffold`** owns the invariant trio (back link, masthead, play/share) + `Hero` and
|
||
`MetaContent` slots; **no body/track-list slot today** (`ReleaseDetailScaffold.razor`).
|
||
- **Data primitives for the Cut page both exist:** `IReleaseDataService.GetById(id)` returns a full
|
||
`ReleaseDto`; the track-data service supports `releaseId`-filtered paging (`ReleaseDetailViewModel.Load`
|
||
uses `GetPage(…, releaseId: …)`). `ReleaseDto` carries `TrackCount` but the **track list needs the
|
||
filtered track page** (the DTO has no nested track collection).
|
||
- **`TrackEntity` ALREADY HAS an explicit track-number ordinal.** `TrackEntity.TrackNumber` (`int`,
|
||
1-based, non-null, default 1 — `TrackEntity.cs:17`); column `track_number` (`TrackConfiguration.cs:37`);
|
||
migration `20260611005700_AddReleaseTypeAndTrackNumber` **already applied**; `TrackDto.TrackNumber`
|
||
mirrors it (`TrackDto.cs:18`); `TrackConverter` round-trips it; `UpdateTrackMetadataRequest.TrackNumber`
|
||
+ `TrackController` validate (`> 0`) and persist; `BatchEdit` assigns ordinal from reorderable list
|
||
position on submit (`BatchEdit.razor:225`); `ReleaseRepository.GetTracks` already
|
||
`.OrderBy(t => t.TrackNumber)` (`ReleaseRepository.cs:117`). **Commitment 5 is verify-and-consume,
|
||
not new schema.** (This corrects the prior draft's "data-model gap" claim — the gap does not exist.)
|
||
- **Release-card markup is triplicated.** `ReleaseGallery` (`release-card`) is the canonical card
|
||
grid, used only by Sessions/Mixes. `ArchiveView` (`archive-release-card`) and `AlbumsView`
|
||
(`album-card`) re-implement the **same cover/title/artist structure inline** — the only real
|
||
divergence is per-card href computation (Archive resolves per-medium, the others use a fixed route).
|
||
- **`Pages.cs` `MenuPages`** = ARCHIVE (→ `/archive`) with Cuts/Sessions/Mixes children; `/tracks`
|
||
and `/genres` are absent from nav, routes reachable (§8.I).
|
||
- **`SharePopover` is track-keyed** (takes `EntryKey`) — sharing a release is a new target.
|
||
- **No release `Description` column exists.** `ReleaseEntity` (`ReleaseEntity.cs:13-30`) carries
|
||
`Title`, `Artist`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `ReleaseType`, `Medium`, `CreatedByUserId?`,
|
||
`Tracks`, `SessionMetadata?`, `MixMetadata?` — no description; `ReleaseDto` (`ReleaseDto.cs:10-33`)
|
||
mirrors it + read-model `TrackCount`; `TrackConverter` (`TrackConverter.cs:19-65`) maps every base
|
||
field both directions with no `Description` line. A grep for `[Dd]escription` across the entity
|
||
returns nothing. **Commitment 8 is a real new column** (contrast commitment 5, already-built).
|
||
- **Release-cardinal fields are written through the track update/upload path, not a release endpoint.**
|
||
There is no `PUT api/release/{id}` metadata endpoint. `BatchEdit`'s `AlbumHeaderFields` owns
|
||
`Genre`/`ReleaseDate`/etc.; on submit each track's `CmsTrackService.UpdateAsync`/`UploadTrackAsync`
|
||
carries those fields and `TrackController` (`PUT api/track/meta/{id}`) projects the release-cardinal
|
||
ones onto the linked release. **Description follows this exact channel** (`UpdateTrackMetadataRequest.cs:14-23`,
|
||
`BatchEdit.razor:380-488`, `AlbumHeaderFields.razor:16-19/80-81`).
|