diff --git a/PLAN.md b/PLAN.md index bd5672b..bab3f9b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -147,6 +147,118 @@ A small set of items that are real but don't fit a phase yet. Surface them when --- +## Phase 9 — Release Medium Types + +Releases gain a top-level **medium** discriminator above the existing `ReleaseType`. Three media: **Studio CUTS** (`Cut` — the only medium that uses Single/EP/Album), **Live SESSIONS** (`Session` — a single live track with a distinct hero image), **DJ MIXES** (`Mix` — a single long track with a preprocessed high-resolution waveform datum). This touches the data model, the API, the CMS, and the public site. + +The public home page **already** carries the three-medium framing as editorial cards (Studio / Live / DJ Mix — `COMPLETED.md §8.6`, landed 2026-06-12), but those cards have no destinations and nothing below the copy layer knows what a medium is. Phase 9 makes the medium real and gives those cards somewhere to point. + +**Architectural spine — discriminator enum + optional metadata table.** `ReleaseMedium` is a plain enum column on `ReleaseEntity`. A medium that needs data beyond the base release (Session's hero image, Mix's waveform datum) gets its own 1:1 metadata table; a medium that needs nothing extra (`Cut`) *is* the base `ReleaseEntity`. This is Open/Closed at the schema level — a future medium (e.g. Video, `§3.1`) adds an enum value and *optionally* one metadata table, and changes **zero** existing tables. The alternatives (one wide nullable table; an EF type hierarchy) both collapse to the god-table the Phase 8 normalization moved away from — rejected. Full design, contracts, and the SOLID rationale: `product-notes/phase-9-release-medium-types.md`. + +**Design discipline throughout: extension, not modification.** Where a per-medium mapping is unavoidable (card → browser, medium → API projection, medium → detail hero), keep it in **one table per concern** — never a scattered three-arm `switch`. Drive CMS cards and nav sub-items off `Enum.GetValues()` + a display-metadata lookup, so a new medium surfaces automatically. + +**The `ReleaseType`-only-for-`Cut` invariant.** Single/EP/Album is meaningful only when `Medium == Cut`. Enforce as a **domain rule** (service layer ignores/resets `ReleaseType` for non-`Cut`; CMS hides the field unless `Cut`), **not** a DB constraint — EF cannot express a conditional constraint cleanly and a raw `CHECK` is migration-fragile. The column stays on `ReleaseEntity` with its default, simply unused for non-`Cut` media. + +Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 2–4 the lettered tracks are parallel. + +**Dependency summary:** `1 → 2 → 3 → 4`. Wave 4 (public site) can begin once Wave 2's `api/release` family is stable; it does not wait on Wave 3 (CMS). + +--- + +### 9.1 Wave 1 — Data model + migration `[prerequisite gate]` + +- **What:** New `ReleaseMedium` enum (`Cut, Session, Mix`) in `DeepDrftModels/Enums/`. `ReleaseEntity` gains `ReleaseMedium Medium` (default `Cut`) plus 1:1 nav properties to two new metadata entities. New `SessionMetadata` (`HeroImagePath`) and `MixMetadata` (`WaveformEntryKey`) entities, each 1:1 with `ReleaseEntity`. EF configurations + migration. +- **Why:** Every other wave reads this schema. The discriminator-plus-optional-table shape is the load-bearing decision of the phase; it must land first and land right. +- **Shape:** + - `ReleaseMedium` enum with `Cut = 0` (default — existing/migrated releases stay studio cuts with no discriminator data migration). + - `Medium` column on `releases`; `ReleaseConfiguration` documents the `ReleaseType`-only-for-`Cut` invariant. + - `session_metadata` and `mix_metadata` tables, each with a unique FK to `releases` (1:1). `MixMetadata.WaveformEntryKey` is a vault entry key (recommended — see open question), not an inline blob. + - Migration is **additive only** — no data migration of existing rows beyond defaulting `Medium = Cut`. Lower risk than the Phase 8 normalization. +- **Prerequisite:** Phase 8 §8.0 normalization (`ReleaseEntity` exists) — already landed. +- **Acceptance criteria:** + - `ReleaseMedium` enum exists; `ReleaseEntity.Medium` defaults to `Cut`. + - `SessionMetadata` / `MixMetadata` entities + EF configs + migration applied; solution compiles and existing releases read back as `Cut`. + - The invariant is documented in `ReleaseConfiguration` (no DB constraint). +- **Open questions:** + - **Waveform storage:** vault blob + `WaveformEntryKey` (recommended — keeps the high-res datum out of SQL, consistent with the dual-database split) vs. a JSON column on `MixMetadata`. Determines whether this wave touches the vault abstraction. Resolve before the migration is written. + +--- + +### 9.2 Wave 2 — API: medium reads + metadata uploads + +A new `api/release` controller — the medium unit is the *release*, not the track, so medium browse and metadata uploads are release-cardinal rather than bolted onto `api/track/page`. + +- **9.2.A — Release read endpoints (data layer + controller).** + - **What:** `GET api/release?medium={cut|session|mix}&page=&pageSize=&sort=` (unauth, paginated, medium filter additive — omitting returns all) and `GET api/release/{id}` (unauth, single release + its medium metadata). Both `Include` the matching metadata table via a per-medium projection map. + - **Why:** The public CUTS/SESSIONS/MIXES surfaces and the CMS browsers all read releases by medium. One cohesive release-read family keeps `api/track/page` focused on Phase 8's track-list cases. + - **Shape:** Repository/service join through the metadata tables only for the relevant medium; base release reads never touch them. Projection map is per-medium (extension-shaped), not an `if/else` chain in the controller. `ReleaseDto` gains `Medium` + optional nested `SessionMetadataDto?` / `MixMetadataDto?` (populated only for the matching medium — mirrors Phase 8's nested-`Release` choice, not denormalized flat fields). + - **Acceptance criteria:** `GET api/release?medium=session` returns Session releases with hero-image metadata included and no `MixMetadata`; `medium=cut` returns Cuts with neither metadata block; pagination + sort parity with `api/track/page`. +- **9.2.B — Metadata upload endpoints.** + - **What:** `POST api/release/session/hero-image` (ApiKey — hero image → image vault → set `SessionMetadata.HeroImagePath`) and `POST api/release/mix/waveform` (ApiKey — preprocessed waveform datum → vault → set `MixMetadata.WaveformEntryKey`). + - **Why:** The CMS authoring flows (Wave 3 B/C) need write paths for the medium-specific data. Splitting them from the track-upload endpoint keeps each endpoint single-responsibility. + - **Shape:** Hero-image upload mirrors the existing cover-art `UploadImageAsync` → image-vault → link pattern, targeting `HeroImagePath`. Waveform upload writes the datum to the vault and records its entry key. Both find-or-create the metadata row for the release. + - **Acceptance criteria:** Posting a hero image to a Session release sets `HeroImagePath` and the image is served back through the existing image proxy; posting a waveform datum to a Mix release sets `WaveformEntryKey` and the datum is retrievable. +- **Prerequisite:** 9.1. +- **Open questions:** + - **New endpoints vs. `api/track/page` query-param extension.** Recommend the new `api/release` family (release-cardinal browse, medium metadata `Include`); `api/track/page` can gain a cheap `medium=` passthrough later if a track-level filter is ever needed. + +--- + +### 9.3 Wave 3 — CMS: Release Archive tab, medium selector, medium browsers + +- **9.3.A — Release Archive tab + medium selector.** + - **What:** Rename `TrackList.razor`'s third tab **Genre → Release Archive**. Inside it, render a **medium card group** (one card per `ReleaseMedium`, styled like the existing `CmsGenreBrowser` cards) where each card *navigates* to a medium-specific browser. Add a `ReleaseMedium` selector to `TrackNew` / `TrackEdit` / `BatchUpload` / `BatchEdit` / `AlbumHeaderFields`; show `ReleaseType` only when `Medium == Cut`, hide it (and surface medium-specific fields) for Session/Mix. + - **Why:** The CMS needs to author medium per release and browse the archive by medium. The card-group-of-media is the CMS analogue of the home page's three-medium block. + - **Shape:** Cards driven by `Enum.GetValues()` + a display-metadata lookup (label/descriptor/swatch) — **no hardcoded card switch**. Cut card → `CmsAlbumBrowser` (reused, with a `MediumFilter`); Session card → `CmsSessionBrowser`; Mix card → `CmsMixBrowser`. Selector-driven conditional fields may be explicit `@if` per medium in the form (three genuinely different field sets — SOLID discipline matters at the data/service layer, not the form markup; do not over-abstract the UI). + - **Acceptance criteria:** The third tab reads "Release Archive" and shows one card per medium; each card navigates to its browser; the upload/edit forms show `ReleaseType` only for `Cut`. +- **9.3.B — `CmsSessionBrowser` + hero-image authoring.** + - **What:** New `CmsSessionBrowser.razor` — a flat list of Session releases (`Medium == Session`) with cover + hero thumbnail, session name, artist; row Edit + hero-image management. Wire the Session upload/edit path to the hero-image upload endpoint (9.2.B). + - **Why:** Sessions are single-track releases with a distinct hero image; the album parent/child expansion of `CmsAlbumBrowser` is the wrong shape for them. + - **Shape:** Reuse `CmsTrackGrid` parameterized by `MediumFilter` where the layout fits; the hero thumbnail is an additive column / thin wrapper, not a forked table. Hero upload reuses the cover-art one-shot pattern against `HeroImagePath`. + - **Acceptance criteria:** Session browser lists only Session releases; uploading a hero image persists it and renders the thumbnail. +- **9.3.C — `CmsMixBrowser` + waveform pipeline.** + - **What:** New `CmsMixBrowser.razor` — a flat list of Mix releases (`Medium == Mix`) with an in-grid waveform-generation **status** column (mirroring Phase 8's `HasWaveformProfile` idiom) and a per-row **Generate Waveform** action. Wire the Mix upload to trigger high-resolution waveform preprocessing and upload the datum (9.2.B). + - **Why:** A Mix without a generated high-res waveform is incomplete; status-in-grid + generate-action is the Phase 8-established pattern for waveform readiness. + - **Shape:** Model the high-res preprocessor on the **existing player-bar waveform preprocessor** — the *same pipeline parameterized by resolution*, not a copy (player-bar peek = low-res, Mix = high-res; honours *One source, multiple views*). Upload flow: `UploadTrackAsync` → preprocess → `POST api/release/mix/waveform`. + - **Acceptance criteria:** Mix browser lists only Mix releases and shows per-row waveform status; uploading a Mix generates + stores a high-res waveform; the per-row Generate action recovers a missing waveform. +- **Prerequisite:** 9.2. +- **Open questions:** + - **Genre browse fate.** Renaming the Genre tab to Release Archive takes its slot. Recommend keeping `CmsGenreBrowser` route-reachable but tab-less (it's built and works), not retiring genre browse wholesale. Confirm with Daniel. + - **Waveform preprocessor reuse.** Is the existing preprocessor factored to accept a resolution parameter, or does 9.3.C include a refactor to share one pipeline across player-bar and Mix? Recommend one parameterized pipeline. + - **Single-track invariant.** Is "single track per Session/Mix" a hard upload constraint (drop the multi-track master list for those media) or a convention? Recommend enforce — it simplifies both form and detail view. + +--- + +### 9.4 Wave 4 — Public site: ARCHIVE nav, CUTS / SESSIONS / MIXES, waveform visualizer + +- **9.4.A — ARCHIVE nav + popover.** + - **What:** Replace the current RELEASES / SESSIONS / MIXES nav items (in `DeepDrftPublic.Client/Layout/Pages.cs`) with a single **ARCHIVE** item. Desktop: hover shows a MudBlazor popover with CUTS / SESSIONS / MIXES → `/cuts`, `/sessions`, `/mixes`. Mobile / direct nav: ARCHIVE → an overview page `/archive` (three medium cards, reusing the §8.6 card idiom). Fixes the current **dead** Sessions/Mixes links. + - **Why:** The nav must route into the new medium surfaces; today's Sessions/Mixes links point nowhere. + - **Shape:** `DeepDrftMenu.razor` renders `Pages.MenuPages` as a flat `` list today with no dropdown mechanism. Recommend extending the nav model with an optional `Children` collection (generalizes to future dropdowns; mobile renders children as indented sub-links) over a bespoke hardcoded popover. + - **Acceptance criteria:** ARCHIVE replaces the three flat items; desktop hover reveals the three sub-links; mobile routes to `/archive`; no dead links remain. +- **9.4.B — CUTS (`/cuts`).** + - **What:** New `/cuts` route reusing the existing `AlbumsView` layout, filtered to `Medium == Cut`. Studio Singles/EPs/Albums appear as they do on the current Releases page. + - **Why:** Honour the existing studio-release browse under the new medium taxonomy. Lowest-effort of the three media. + - **Shape:** Parameterize `AlbumsView`'s data load with a medium filter rather than forking a component. `/cuts` = `AlbumsView` with `Medium == Cut`. + - **Acceptance criteria:** `/cuts` shows only `Cut` releases with the current AlbumsView ergonomics. + - **Open question:** Does the current Releases/`AlbumsView` route redirect to `/cuts` or stay an all-media view? Small routing call. +- **9.4.C — SESSIONS (`/sessions` + `/sessions/{id}`).** + - **What:** Gallery of session cards (cover, session name, artist) at `/sessions`; detail at `/sessions/{id}` mirroring `TrackDetail` but with the **hero image dominant above the fold**, cover secondary. + - **Why:** Sessions are an authored content kind the home page advertises; the hero image is their distinctive visual. + - **Shape:** Gallery borrows `AlbumsView`'s card-gallery skeleton with a session card face. Detail composes a shared `ReleaseDetailScaffold` (extracted common metadata + play + player wiring) with a hero-image hero slot — see 9.4.D open question. + - **Acceptance criteria:** `/sessions` lists Session releases; `/sessions/{id}` renders hero-dominant with the play affordance intact. +- **9.4.D — MIXES (`/mixes` + `/mixes/{id}`) + `MixWaveformVisualizer`.** + - **What:** Gallery at `/mixes`; detail at `/mixes/{id}` whose above-the-fold hero is a **`MixWaveformVisualizer`** component fed by the preprocessed waveform datum from `MixMetadata`. The visualizer is a **named, reusable** component. + - **Why:** Mixes are long continuous sets; the waveform is their signature visual and the brief calls for a reusable visualizer. + - **Shape:** `MixWaveformVisualizer` takes the waveform datum (via `WaveformEntryKey` → content endpoint) + optional playback-position binding; renders SVG/canvas peak-bars in the established `SpectrumVisualizer` / `LevelMeterFab` visual language (don't invent a new idiom). Detail composes the same `ReleaseDetailScaffold` with the visualizer as its hero slot. + - **Acceptance criteria:** `/mixes` lists Mix releases; `/mixes/{id}` renders the waveform visualizer fed by real datum; the visualizer is a standalone reusable component. + - **Open question:** Design the visualizer's seek-on-click position-binding seam now even if click-to-seek ships later? Recommend yes — design the seam, defer the feature (*Design for adaptability up front*). +- **Prerequisite:** 9.2 (the `api/release` read family). Independent of Wave 3. +- **Open questions:** + - **Detail-page strategy.** Three separate detail pages vs. one branching `TrackDetail` vs. a shared `ReleaseDetailScaffold` + per-medium hero slot. Recommend the scaffold (DRY-by-composition, the Phase 8 `BatchUpload`/`BatchEdit` extraction move; honours *One source, multiple views*). Sets the shape of 9.4.C and 9.4.D. + +--- + ## Working with this file - **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing. diff --git a/product-notes/phase-9-release-medium-types.md b/product-notes/phase-9-release-medium-types.md new file mode 100644 index 0000000..5b99ba8 --- /dev/null +++ b/product-notes/phase-9-release-medium-types.md @@ -0,0 +1,467 @@ +# Phase 9 — Release Medium Types + +Status: spec / design. Author: product-designer. Date: 2026-06-12. +**Plan only — no code edits made by this doc.** + +Cross-references: `PLAN.md §9` (the concise phase entry, four waves), `COMPLETED.md §8.6` +("Music through Every Medium" home-page section — the public-facing three-medium framing already +landed and these browse surfaces are where those cards point), `product-notes/phase-8-cms-track-browser.md` +(the `ReleaseEntity` normalization and the CMS browser components this phase extends), memory +*One source, multiple views* and *Design for adaptability up front*. + +--- + +## 0. Why this phase exists, and what already landed + +Phase 8 normalized the schema into `ReleaseEntity` + slim `TrackEntity`, and built the CMS browse +surface (`CmsTrackGrid`, `CmsAlbumBrowser`, `CmsGenreBrowser`) plus the public `AlbumsView` / +`GenresView`. COMPLETED §8.6 then pivoted the public home page from a genre-taxonomy block to a +**three-medium** editorial block: **Studio / Live / DJ Mix**. So the medium vocabulary is *already +user-visible* — but it is purely presentational copy on the home page today. Nothing in the data +model, the CMS, or the browse routes knows what a "medium" is. + +Phase 9 makes the medium real: a top-level discriminator on every release, medium-specific metadata +where a medium needs more than a Studio cut does, and the CMS + public surfaces to author and browse +by medium. The home-page cards from §8.6 get real destinations. + +**The three media (Daniel's framing):** + +| Medium | Enum | What it is | Extra data beyond a Release | +|---|---|---|---| +| Studio CUTS | `Cut` | Studio recordings. **The only medium that uses `ReleaseType`** (Single/EP/Album). | none — a Cut *is* the base `ReleaseEntity` | +| Live SESSIONS | `Session` | A single live track with a distinct **hero image** (separate from cover art). | `SessionMetadata` (hero image) | +| DJ MIXES | `Mix` | A single long track with a preprocessed **high-resolution waveform** datum. | `MixMetadata` (waveform datum ref) | + +--- + +## 1. The core design move — discriminator enum + optional metadata table + +This is the SOLID spine of the phase, and the thing most worth getting right because "there may be +additional release varieties in the future." + +**The pattern:** `ReleaseMedium` is a top-level enum discriminator on `ReleaseEntity`. Each medium +that needs data *beyond* the base release fields gets its **own 1:1 metadata table** keyed to the +release. A medium that needs nothing extra (Studio `Cut`) gets no table — it *is* the base +`ReleaseEntity`. + +``` +ReleaseEntity +├── Medium: ReleaseMedium (Cut | Session | Mix) ← the discriminator +├── ReleaseType (valid only when Medium == Cut) +├── ...base release fields... +│ +├── SessionMetadata? 1:1, present iff Medium == Session { HeroImagePath } +└── MixMetadata? 1:1, present iff Medium == Mix { WaveformEntryKey } +``` + +### Why this over the alternatives + +Three shapes were on the table: + +**(A) Single wide table.** Add `HeroImagePath` and `WaveformEntryKey` (and every future medium's +columns) directly onto `ReleaseEntity`, nullable. *Rejected.* Every new medium widens the central +table with columns that are null for every other medium. The table becomes a union of all media, +and the "valid only when Medium == X" invariant multiplies across columns with no structural support. +This is the "god table" anti-pattern — exactly what Phase 8 normalized *away* from. + +**(B) Table-per-Hierarchy (EF TPH discriminator with subclasses).** `SessionRelease : ReleaseEntity`, +`MixRelease : ReleaseEntity`, EF maps them into one table with a discriminator column. *Rejected for +now.* TPH still collapses to one wide table at the storage layer (same null-column sprawl as A), and +TPT (table-per-type) forces a subclass even for media that add nothing, plus complicates the +`Tracks` collection and every query that doesn't care about medium. The polymorphism buys little +when most reads are "give me releases of medium X" — a discriminator column answers that without a +type hierarchy. + +**(C) Discriminator enum + optional sibling metadata tables (RECOMMENDED).** The medium is a plain +enum column — cheap to filter, cheap to index, trivially extensible (add an enum value). Medium- +specific data lives in its own table, joined only when that medium is queried. Adding a future +medium is: add an enum value; *if* it needs extra data, add one metadata table. The base +`ReleaseEntity` never changes shape. This is Open/Closed at the schema level — extension by addition, +not modification. + +**The cost of (C):** medium-specific reads need a join (or an `Include`) to pull the metadata. That +is the right cost to pay — it is paid only on the medium-specific surfaces (Session detail, Mix +detail), and the base release queries (the bulk of traffic) never touch the metadata tables. + +### The `ReleaseType`-only-for-Cut invariant + +`ReleaseType` (Single/EP/Album) is semantically meaningful **only when `Medium == Cut`**. For +Session and Mix it is noise. The question is how to enforce it. + +**Recommendation: domain rule, not DB constraint.** EF Core cannot express "this column is required +iff a sibling column equals a value" as a clean check constraint, and a raw SQL `CHECK` would be +opaque and migration-fragile. Instead: + +- **Service layer** is the enforcement point. `ReleaseType` is *ignored* (or reset to its default) + when `Medium != Cut` on write; readers of a non-Cut release should not surface it. +- **CMS validation** hides the `ReleaseType` field entirely unless `Medium == Cut` (so an admin + cannot set a contradictory value in the first place). +- **Leave the column on `ReleaseEntity`** with its existing default. It is simply unused for non-Cut + media. This keeps the schema flat for the base entity and avoids a nullable-everywhere refactor. + +Document the invariant in `ReleaseConfiguration` and the service so future readers know `ReleaseType` +on a Session/Mix is meaningless, not missing. + +--- + +## 2. Data contracts + +### 2.1 New enum + +```csharp +// DeepDrftModels/Enums/ReleaseMedium.cs +public enum ReleaseMedium +{ + Cut, // Studio recording — uses ReleaseType (Single/EP/Album) + Session, // Single live track + hero image + Mix // Single long track + preprocessed waveform datum +} +``` + +`Cut` is value 0 so it is the default, matching the brief and keeping existing/migrated releases as +studio cuts without a data migration of the discriminator itself. + +### 2.2 `ReleaseEntity` gains + +```csharp +public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut; +public SessionMetadata? SessionMetadata { get; set; } // nav, 1:1, present iff Medium == Session +public MixMetadata? MixMetadata { get; set; } // nav, 1:1, present iff Medium == Mix +``` + +### 2.3 New metadata entities + +```csharp +// SessionMetadata — 1:1 with ReleaseEntity, present only for Session releases +public class SessionMetadata : BaseEntity, IEntity +{ + public long ReleaseId { get; set; } // FK + 1:1 (unique) + public ReleaseEntity Release { get; set; } + public required string HeroImagePath { get; set; } // entry key in the image vault +} + +// MixMetadata — 1:1 with ReleaseEntity, present only for Mix releases +public class MixMetadata : BaseEntity, IEntity +{ + public long ReleaseId { get; set; } // FK + 1:1 (unique) + public ReleaseEntity Release { get; set; } + public required string WaveformEntryKey { get; set; } // entry key for the waveform datum in the vault +} +``` + +**Open question — waveform storage shape.** Two readings of "preprocessed waveform datum": + +- **(i) Vault blob + entry-key reference (RECOMMENDED).** The high-resolution waveform is a binary + datum stored in a FileDatabase vault (mirroring how audio binaries live in the `tracks` vault), + and `MixMetadata.WaveformEntryKey` is the lookup key. Keeps the (potentially large) datum out of + the SQL row, consistent with the dual-database split (SQL = metadata, vault = binary). This is the + recommendation — the existing player-bar waveform preprocessing already produces a byte-level + datum; the Mix variant is the same pipeline at higher resolution, stored durably in the vault + rather than computed per-play. +- **(ii) JSON column on `MixMetadata`.** Store the waveform points as a JSON array directly in SQL. + Simpler (no vault round-trip) but bloats the SQL row and breaks the "binary lives in the vault" + rule. Only defensible if the datum is small (a few hundred points). *Not recommended* for a + *high-resolution* waveform. + +Recommend (i). Flag for Daniel — it determines whether Wave 1 touches the vault abstraction or just +adds a SQL column. + +### 2.4 DTOs + +- `ReleaseDto` gains `ReleaseMedium Medium`. +- New `SessionMetadataDto { HeroImagePath }` and `MixMetadataDto { WaveformEntryKey }`. +- `ReleaseDto` gains optional `SessionMetadata? SessionMetadata` / `MixMetadata? MixMetadata` + (populated on reads of the relevant medium, null otherwise — mirroring the nested-`Release` + pattern Phase 8 chose for `TrackDto`). Do **not** denormalize hero-image / waveform onto every + `ReleaseDto`; they ride the nested metadata object, present only for the matching medium. + +--- + +## 3. CMS surface + +### 3.1 "Release Archive" tab (was "Genre") + +The third tab in `TrackList.razor` is renamed **Genre → Release Archive**. Today that tab renders +`CmsGenreBrowser` (a card grid + accordion). The Release Archive tab instead renders a **medium +card group** — one card per `ReleaseMedium` value, styled like the existing `CmsGenreBrowser` cards +(same `MudCard` + swatch idiom, see the verified markup in §6). The medium cards do *not* expand +inline; each **navigates** into a medium-specific browser. + +**SOLID note — drive the cards off the enum, not a hardcoded list.** Render one card per +`Enum.GetValues()` (with a small display-metadata lookup for label / descriptor / +swatch — a `static IReadOnlyDictionary` or a `[Display]` attribute +read via reflection). Adding a future medium then surfaces a new card automatically. **Do not write a +three-arm `switch` in the markup** — that is the modification-not-extension trap the phase is meant +to avoid. The *navigation target* per medium is the one place a mapping is unavoidable; keep that +mapping in one table, not scattered. + +| Medium card | Navigates to | Browser component | +|---|---|---| +| Studio (Cut) | existing album browse, filtered `Medium == Cut` | `CmsAlbumBrowser` (reused, with a `MediumFilter`) | +| Live (Session) | new | `CmsSessionBrowser` (new) | +| DJ Mix (Mix) | new | `CmsMixBrowser` (new) | + +What the Genre browse becomes: the genre card grid does not disappear from the product — genre is +still a meaningful axis — but it is no longer the *third top-level tab*. Decide with Daniel whether +genre browsing (a) moves under the Studio/Cut browser as a secondary filter, (b) stays reachable via +a route but loses its tab, or (c) is retired in the CMS. **Recommend (b)** — keep `CmsGenreBrowser` +reachable (it's built and works) but give the top-level third tab to Release Archive. *Open question +for Daniel — the brief says "rename," which implies the genre tab's slot is taken; confirm genre +browse isn't being dropped wholesale.* + +### 3.2 New browsers + +- **`CmsSessionBrowser.razor`** — single-track sessions. Card/row per Session release showing cover + + hero thumbnail, session name, artist. Reuses `CmsTrackGrid`-style data access filtered to + `Medium == Session`; each session is one track, so the album-parent/child expansion of + `CmsAlbumBrowser` is unnecessary — a flat list is the right shape. Row Edit → `TrackEdit` (or a + session-aware edit); row actions include hero-image management. +- **`CmsMixBrowser.razor`** — single-track mixes. Flat list filtered to `Medium == Mix`. Each row + shows waveform-generation status (mirroring the Phase 8 `HasWaveformProfile` in-grid status idiom — + a Mix without a generated high-res waveform is incomplete). Per-row "Generate Waveform" action. + +Both reuse `CmsTrackGrid` where the layout fits, parameterized by `MediumFilter` — same +"one grid, parameterized" DRY move Phase 8 established for genre. Where a medium's list genuinely +differs (hero thumb column for sessions, waveform-status column for mixes), those are additive +columns on a shared grid or a thin medium-specific wrapper — **not** a forked table. + +### 3.3 Medium selector on the upload/edit forms + +`TrackNew.razor`, `TrackEdit.razor`, `BatchUpload.razor`, `BatchEdit.razor`, and the shared +`AlbumHeaderFields.razor` gain a **`ReleaseMedium` selector** (`MudSelect`). + +Conditional fields driven by the selector: + +- `Medium == Cut` → show the existing `ReleaseType` (Single/EP/Album) field. Show album-header + multi-track ergonomics as today. +- `Medium == Session` → **hide** `ReleaseType`; show a **hero-image upload** field (in addition to + cover art). Constrain to a single track. +- `Medium == Mix` → **hide** `ReleaseType`; the upload triggers **waveform preprocessing** (§3.4). + Constrain to a single track. + +The conditional rendering should key off the enum, not a cascade of `@if (medium == X)` blocks where +avoidable — but with only three media and genuinely different field sets, a small `@if` per medium +in the form is acceptable and clearer than over-abstracting. The SOLID discipline matters most at the +*data/service* layer; a form is allowed to be explicit. Flag the tension, don't over-engineer the UI. + +### 3.4 Mix waveform pipeline (CMS-triggered) + +When a Mix is uploaded, the CMS triggers the **high-resolution waveform preprocessor** and uploads +the resulting datum to the vault. Model this on the **existing player-bar waveform preprocessing** +(the pipeline that already produces a byte-level waveform datum), but: + +- produce a **high-resolution** datum (more sample points than the player-bar peek), +- store it durably in the vault (not compute-per-play), +- record its `WaveformEntryKey` in `MixMetadata`. + +The CMS upload flow becomes: upload audio (existing `UploadTrackAsync`) → trigger waveform +preprocessing → `POST api/release/mix/waveform` with the datum → service writes datum to vault + +sets `MixMetadata.WaveformEntryKey`. The per-row "Generate Waveform" action in `CmsMixBrowser` is the +recovery path for a mix whose waveform failed or predates the feature. + +**Reuse point.** The existing waveform preprocessor should be the *same code path* parameterized by +resolution, not a copy. If the current preprocessor isn't factored to allow a resolution parameter, +that refactor is part of Wave 3 track C — flag it. (This honours the *One source, multiple views* +preference: the player-bar peek and the Mix high-res datum are two resolutions of one pipeline, not +two pipelines.) + +### 3.5 Session hero-image pipeline (CMS) + +Session uploads provide a **hero-image** upload path (distinct from cover art). The hero image is +stored in the **image vault** (same vault as cover art, different entry key) and recorded in +`SessionMetadata.HeroImagePath`. Flow: `POST api/release/session/hero-image` (multipart) → image +vault write → `SessionMetadata.HeroImagePath` set. Mirrors the existing cover-art +`UploadImageAsync` + link-via-`UpdateAsync` pattern Phase 8 documented; the only difference is the +target field. + +--- + +## 4. API surface + +The phase prefers **a new `release` controller** over bolting medium concerns onto the track +endpoints, because the unit of medium is the *release*, not the track. Endpoints: + +| Endpoint | Auth | Purpose | +|---|---|---| +| `GET api/release?medium={cut\|session\|mix}&page=&pageSize=&sort=` | unauth (public reads) | Paginated releases of a medium, with medium-specific metadata `Include`d. The medium filter is additive — omitting it returns all releases. | +| `GET api/release/{id}` | unauth | Single release + its medium metadata (hero image for Session, waveform key for Mix). Feeds the public detail views. | +| `POST api/release/session/hero-image` | ApiKey | Upload hero image → image vault → set `SessionMetadata.HeroImagePath`. | +| `POST api/release/mix/waveform` | ApiKey | Upload preprocessed waveform datum → vault → set `MixMetadata.WaveformEntryKey`. | + +**Decision — new endpoints vs. query-param extension of `api/track/page`.** The brief offers either. +Recommend a **new `api/release` family** rather than overloading `api/track/page`: + +- The browse axis is now the *release* (medium lives on the release, and Session/Mix are + single-track releases where "list of tracks" is the wrong primary shape). +- Medium-specific metadata (`Include` of `SessionMetadata` / `MixMetadata`) belongs on a release + read, not a track-page read. +- `api/track/page` stays focused on the track-list use cases Phase 8 built; it can *gain* a + `medium=` passthrough filter cheaply if a track-level medium filter is ever needed, but the + primary medium-browse path is release-cardinal. + +This keeps each endpoint cohesive (SRP at the HTTP boundary) rather than growing `api/track/page` +into the everything-endpoint. + +**Extensibility note.** `GET api/release?medium=` should accept *any* `ReleaseMedium` value and +`Include` the matching metadata via a small per-medium projection map — not a hardcoded +`if session … else if mix …` chain in the controller. A future medium adds a projection entry, not a +new endpoint branch. Same Open/Closed discipline as the CMS cards. + +--- + +## 5. Public site surface + +### 5.1 ARCHIVE nav with sub-items + +Replace the current **RELEASES / SESSIONS / MIXES** nav links (defined in +`DeepDrftPublic.Client/Layout/Pages.cs` — note: `Pages.cs` lives in `Layout/`, not `Pages/`) with a +single **ARCHIVE** item. + +- **Desktop:** hovering ARCHIVE shows a **MudBlazor popover** (`MudMenu` on hover, or a `MudPopover` + triggered on mouse-enter) with three sub-items: **CUTS / SESSIONS / MIXES** → `/cuts`, + `/sessions`, `/mixes`. +- **Mobile / direct nav:** ARCHIVE links to an **overview page** (`/archive`) — the three media as + large cards, the mobile-friendly equivalent of the desktop popover. (The home-page §8.6 three-card + block is the design precedent for this overview; `/archive` can reuse that card idiom.) + +This fixes the current **dead links** — today's "Sessions" and "Mixes" nav items point nowhere. They +resolve to the new `/sessions` and `/mixes` routes. + +**Nav data note.** `DeepDrftMenu.razor` renders `Pages.MenuPages` as a flat `` list (verified). +ARCHIVE-with-popover needs a nav item that can carry *children*. Either (a) extend the `MenuPages` +model with an optional `Children` collection and special-case rendering of items that have children, +or (b) hardcode ARCHIVE as a distinct popover component in `DeepDrftMenu` alongside the flat list. +**Recommend (a)** — a `Children` collection on the nav model generalizes (a future "About" dropdown +gets it free) and keeps the menu data-driven. Mobile renders children as indented sub-links inside +the existing hamburger panel. + +### 5.2 CUTS — `/cuts` + +Reuses the existing **`AlbumsView`** layout, filtered to `Medium == Cut`. Studio Singles / EPs / +Albums all appear here exactly as the current Releases page shows them. Lowest-effort of the three — +it is the current `AlbumsView` with a medium filter on its data source. + +**Recommendation:** parameterize `AlbumsView`'s data load with a medium filter rather than forking a +new component. `/cuts` is `AlbumsView` with `Medium == Cut`; if a future "/all releases" view wants +the unfiltered set, the same component serves it. (Whether the *old* `/releases`-style route +redirects to `/cuts` or is retired is a small routing call — flag.) + +### 5.3 SESSIONS — `/sessions` + `/sessions/{id}` + +- **Gallery (`/sessions`):** card grid of session cards — cover image, session name, artist. New + component, but borrow `AlbumsView`'s card-gallery skeleton (it's the same gallery shape with a + different card face). +- **Detail (`/sessions/{id}`):** mirrors `TrackDetail` but the **hero image is the dominant + above-the-fold visual**, cover art secondary. New `SessionDetail` page (or `TrackDetail` + parameterized with a "hero-dominant" layout variant — see below). + +**Reuse decision — new pages vs. parameterized `TrackDetail`.** `TrackDetail` today is cover-led. +Session detail is hero-led; Mix detail is waveform-led. Three readings: + +- **(i) Three separate detail pages.** Clearest per-medium, most duplication of the shared scaffolding + (play affordance, metadata block, player wiring). +- **(ii) One `TrackDetail` with a layout-variant switch.** Least duplication, but a single component + branching on medium for above-the-fold layout gets busy. +- **(iii) Shared scaffolding component + per-medium "hero slot" (RECOMMENDED).** Extract the common + detail scaffolding (metadata, play control, player wiring) into a `ReleaseDetailScaffold` that + takes a `RenderFragment HeroContent`. `CutDetail`/`SessionDetail`/`MixDetail` each supply their + hero (cover, hero image, waveform visualizer respectively) and compose the shared scaffold. This is + the same DRY-by-composition move Phase 8 used for `BatchUpload`/`BatchEdit` sub-component + extraction — and it honours *One source, multiple views*: one scaffold, three hero renderings. + +Recommend (iii). It bounds the duplication while letting each medium own its distinctive +above-the-fold without polluting the others. + +### 5.4 MIXES — `/mixes` + `/mixes/{id}` + +- **Gallery (`/mixes`):** card grid like sessions. +- **Detail (`/mixes/{id}`):** the hero slot is a **`MixWaveformVisualizer`** component fed by the + preprocessed waveform datum from `MixMetadata.WaveformEntryKey`. Designed as a **named, reusable + component** (the brief is explicit) so it can be reused — e.g., a future inline waveform on the + player bar, or a mix card preview. + +**`MixWaveformVisualizer` design notes.** + +- **Input:** the waveform datum (fetched via the `WaveformEntryKey` → vault read, served through a + content endpoint like the existing audio/image proxies). Component takes the datum (or a URL to + it) + optional playback-position binding. +- **Rendering:** SVG or canvas peak-bars, consistent with the existing `SpectrumVisualizer` / + `LevelMeterFab` visual language already in the player stack (don't invent a new visual idiom — + borrow the established peak-bar look). The §8 player-bar waveform is the low-res cousin; this is its + high-res, full-width sibling. +- **Interactivity (optional, flag):** clicking the waveform could seek (the streaming player already + supports seek-beyond-buffer). Worth designing the component's position-binding seam *now* even if + seek-on-waveform-click is deferred — designing the seam costs little, backfilling it costs a + rewrite. (Memory: *Design for adaptability up front* — defer the feature, design the seam.) + +--- + +## 6. Verified facts (read against live source 2026-06-12) + +- `ReleaseEntity` exists (`DeepDrftModels/Entities/ReleaseEntity.cs`), inherits `BaseEntity`, has + `Title, Artist, Genre?, ReleaseDate?, ImagePath?, ReleaseType (default Single), CreatedByUserId?, + Tracks`. **No `Medium` field yet.** +- `ReleaseType` enum (`DeepDrftModels/Enums/ReleaseType.cs`): `Single, EP, Album`. +- CMS browser components **already exist** (Phase 8 landed): `CmsTrackGrid.razor`, + `CmsAlbumBrowser.razor`, `CmsGenreBrowser.razor` in `DeepDrftManager/Components/Pages/Tracks/`. +- `CmsGenreBrowser` card idiom (verified): `MudGrid Spacing=3` → `MudItem xs=12 sm=6 md=4` → + `MudCard` with an `@onclick` toggle, a swatch `
`, and `MudCardContent` (`Typo.h6` name + + `Typo.body2` count). The Release Archive medium cards should match this idiom. `CmsTrackGrid` + already takes `GenreFilter` / `ShowAddButton` parameters — the precedent for a `MediumFilter`. +- Public nav: `DeepDrftMenu.razor` renders `Pages.MenuPages` as a **flat `` list** (desktop) and + inside a hamburger panel (mobile). `Pages.cs` lives in **`DeepDrftPublic.Client/Layout/`** (the + root `CLAUDE.md` for the client confirms `Layout/Pages.cs`, "MenuPages for header, AllPages for + exhaustive list"). The nav has **no popover/dropdown mechanism today** — ARCHIVE introduces the + first one. +- Public home page **already** carries the three-medium framing (Studio / Live / DJ Mix) as + editorial cards — COMPLETED §8.6, landed 2026-06-12. Those cards currently have no destinations; + Phase 9's `/cuts`, `/sessions`, `/mixes` (or `/archive`) are where they should point. +- The dual-database split: SQL = metadata (EF), vault = binary (FileDatabase). Waveform datum + (recommended) and hero/cover images live vault-side; medium discriminator + metadata-table rows + live SQL-side. + +--- + +## 7. Open questions (need Daniel before build) + +1. **Waveform storage shape (§2.3).** Vault blob + `WaveformEntryKey` (recommended) vs. JSON column + on `MixMetadata`. Determines whether Wave 1 touches the vault abstraction. *Recommend vault blob.* +2. **Genre browse fate (§3.1).** "Rename the Genre tab to Release Archive" takes the third tab's + slot. Does genre browsing survive (route-reachable but tab-less — recommended), move under the + Cut browser as a secondary filter, or retire in the CMS? *Recommend keep route-reachable.* +3. **Waveform preprocessor reuse (§3.4).** Is the existing player-bar waveform preprocessor factored + to accept a resolution parameter, or does Wave 3 track C include a refactor to share one pipeline + across player-bar (low-res) and Mix (high-res)? *Recommend one parameterized pipeline.* +4. **Detail-page strategy (§5.3).** Three separate detail pages vs. one branching `TrackDetail` vs. + shared `ReleaseDetailScaffold` + per-medium hero slot (recommended). Sets the public-site Wave 4 + shape. *Recommend the scaffold.* +5. **Old releases route (§5.2).** Does the current Releases/`AlbumsView` route redirect to `/cuts`, + or stay as an all-media view? *Small routing call.* +6. **Nav model children (§5.1).** Extend `MenuPages` with an optional `Children` collection + (recommended, generalizes) vs. hardcode ARCHIVE as a bespoke popover component. *Recommend the + model extension.* +7. **`MixWaveformVisualizer` seek-on-click (§5.4).** Design the position-binding seam now even if + click-to-seek ships later? *Recommend design the seam, defer the feature.* +8. **Single-track invariant for Session/Mix (§3.3).** Sessions and Mixes are "single-track." Is that + a hard constraint enforced at upload (one track per Session/Mix release), or a convention? If + hard, the CMS upload form for those media should drop the multi-track master list entirely. + *Recommend enforce — it simplifies both the form and the detail view.* + +--- + +## 8. SOLID summary — why this is extension-shaped + +The phase is designed so a **fourth medium** (say, "Video," already hinted in `PLAN.md §3.1`) costs: + +1. one new `ReleaseMedium` enum value, +2. *if* it needs extra data, one new metadata table + DTO, +3. one display-metadata entry (so the CMS card + nav sub-item appear automatically), +4. one projection entry in the `api/release?medium=` map, +5. its own hero-slot renderer for the detail scaffold. + +It costs **zero** changes to: the base `ReleaseEntity` shape, the other media's tables, the existing +browse grids, or the existing detail scaffolding. That is the Open/Closed payoff of discriminator- +enum + optional-metadata-table over a wide table or a type hierarchy. Where the design *does* admit a +mapping (card → browser, medium → projection, medium → hero renderer), that mapping is kept in **one +table per concern**, never duplicated as scattered `switch`/`if` chains. That single-table-of-mappings +discipline is the difference between "extensible" and "extensible on paper."