docs: amend Phase 9 spec — apply SOLID review fixes F0-F13
This commit is contained in:
@@ -50,7 +50,7 @@ ReleaseEntity
|
||||
├── ReleaseType (valid only when Medium == Cut)
|
||||
├── ...base release fields...
|
||||
│
|
||||
├── SessionMetadata? 1:1, present iff Medium == Session { HeroImagePath }
|
||||
├── SessionMetadata? 1:1, present iff Medium == Session { HeroImageEntryKey }
|
||||
└── MixMetadata? 1:1, present iff Medium == Mix { WaveformEntryKey }
|
||||
```
|
||||
|
||||
@@ -58,7 +58,7 @@ ReleaseEntity
|
||||
|
||||
Three shapes were on the table:
|
||||
|
||||
**(A) Single wide table.** Add `HeroImagePath` and `WaveformEntryKey` (and every future medium's
|
||||
**(A) Single wide table.** Add `HeroImageEntryKey` 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.
|
||||
@@ -88,9 +88,12 @@ detail), and the base release queries (the bulk of traffic) never touch the meta
|
||||
`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:
|
||||
**Recommendation: domain rule, not DB constraint — by choice, not necessity.** EF Core *can*
|
||||
express this constraint: check-constraint support is first-class
|
||||
(`ToTable(t => t.HasCheckConstraint(...))`), generated and versioned in migrations, and fully
|
||||
supported by Npgsql. The honest position is that we **choose** not to constrain, because the
|
||||
invariant is advisory ("meaningless," not "invalid" — a stale `Single` on a Session row harms
|
||||
nothing) and the read model enforces it at a single mapping point (§2.4). 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.
|
||||
@@ -99,8 +102,23 @@ opaque and migration-fragile. Instead:
|
||||
- **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.
|
||||
**Named exception — `ReleaseType` is Cut-specific data on the base table.** By this spec's own
|
||||
option-(A) rejection, `ReleaseType` is "a column that is meaningless for every other medium" and the
|
||||
rigorous application of the pattern would be a `CutMetadata { ReleaseType }` table, making Cut
|
||||
symmetric with Session and Mix. That option was considered and **deliberately rejected**:
|
||||
|
||||
- **Hot path.** The `/cuts` gallery (the highest-traffic browse read) displays Single/EP/Album on
|
||||
every card. Moving `ReleaseType` into a metadata table would put a join on that hot path — the
|
||||
very cost this design otherwise pays only on rare, single-row medium-specific reads.
|
||||
- **Churn.** Phase 8 §8.0 just landed `ReleaseEntity` with `ReleaseType`; relocating it weeks after
|
||||
a breaking normalization is a data migration for structural purity with no behavioral win.
|
||||
|
||||
This is a **named, reasoned exception, not a precedent**. Future medium designers must not
|
||||
cargo-cult a base-table column for *their* medium's data — the default remains the metadata table —
|
||||
nor "fix" `ReleaseType` into a table without weighing the read-path cost above.
|
||||
|
||||
Document the invariant *and this exception* in `ReleaseConfiguration` and the service so future
|
||||
readers know `ReleaseType` on a Session/Mix is meaningless, not missing.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +155,7 @@ 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
|
||||
public required string HeroImageEntryKey { get; set; } // entry key in the image vault
|
||||
}
|
||||
|
||||
// MixMetadata — 1:1 with ReleaseEntity, present only for Mix releases
|
||||
@@ -149,7 +167,8 @@ public class MixMetadata : BaseEntity, IEntity
|
||||
}
|
||||
```
|
||||
|
||||
**Open question — waveform storage shape.** Two readings of "preprocessed waveform datum":
|
||||
**Waveform storage shape — resolved (see §7.1).** Two readings of "preprocessed waveform datum"
|
||||
were on the table:
|
||||
|
||||
- **(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),
|
||||
@@ -163,13 +182,21 @@ public class MixMetadata : BaseEntity, IEntity
|
||||
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.
|
||||
**Resolved: (i) vault blob.** Settled by the server-side waveform-trigger design (§3.4) — the API
|
||||
computes and stores the datum vault-side and records only the entry key in SQL, so a JSON column
|
||||
never enters the flow. Wave 1 adds only the SQL column; the vault write rides the existing vault
|
||||
abstraction server-side.
|
||||
|
||||
### 2.4 DTOs
|
||||
|
||||
- `ReleaseDto` gains `ReleaseMedium Medium`.
|
||||
- New `SessionMetadataDto { HeroImagePath }` and `MixMetadataDto { WaveformEntryKey }`.
|
||||
- `ReleaseDto.ReleaseType` becomes **nullable** (`ReleaseType?`), nulled at the single entity→DTO
|
||||
mapping point when `Medium != Cut`. A non-nullable mirror of the entity would serialize a
|
||||
confidently wrong `Single` on every Session/Mix and turn the "readers should not surface it" rule
|
||||
into a discipline imposed on every consumer. Nullable-at-the-mapping-point means **one producer
|
||||
enforces the invariant; zero consumers need to know the rule**. (The *entity* column stays
|
||||
non-nullable — this is a read-model fix.)
|
||||
- New `SessionMetadataDto { HeroImageEntryKey }` 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
|
||||
@@ -239,38 +266,51 @@ Conditional fields driven by the selector:
|
||||
- `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.
|
||||
**One dispatch point, not five scattered conditionals.** A small `@if` per medium would be fine in
|
||||
*one* form — but the same medium-conditional logic appears in five files, which is exactly the
|
||||
scattered-`switch` smell §8 forbids, and the resolved single-track invariant (§7.8) raises the
|
||||
stakes: the conditionals gate structural form shape (multi-track master list present/absent), not
|
||||
just a field or two. Instead, extract **per-medium field-section components** — `CutFields`,
|
||||
`SessionFields`, `MixFields` — with plain explicit markup inside (no clever generics; the
|
||||
anti-over-abstraction instinct is right *within* a section). **One dispatch point** — a
|
||||
`MediumFields` component (or equivalent) holding the single `@switch` — is embedded by all five
|
||||
forms. Adding a fourth medium is then one new section component + one dispatch entry, and §8 can
|
||||
cite that cost honestly.
|
||||
|
||||
### 3.4 Mix waveform pipeline (CMS-triggered)
|
||||
### 3.4 Mix waveform pipeline (server-side trigger)
|
||||
|
||||
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:
|
||||
When a Mix is uploaded, the CMS calls a **server-side waveform trigger** — it does **not** compute
|
||||
or carry the datum itself. `POST api/release/{id}/mix/waveform` (ApiKey, **no request body**): the
|
||||
API fetches the mix audio from its own vault, computes the **high-resolution** waveform via
|
||||
`WaveformProfileService` parameterized by resolution, stores the datum durably in the vault, and
|
||||
sets `MixMetadata.WaveformEntryKey`. This mirrors the existing low-res idiom
|
||||
(`POST api/track/{trackId}/waveform` is body-less; the server computes from vault audio it already
|
||||
owns) and makes the derived datum **correct by construction** — no trusting an ApiKey holder's
|
||||
blob, and no inverting the authority by having a network client compute what the server can derive
|
||||
from its own data.
|
||||
|
||||
- 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`) → call the trigger. No
|
||||
datum computation client-side — the CMS has no in-process data layer by standing convention and
|
||||
must not grow one (or a copy of the preprocessor) for this. The per-row "Generate Waveform" action
|
||||
in `CmsMixBrowser` is the recovery path for a mix whose waveform failed or predates the feature —
|
||||
the same body-less trigger, with no download/recompute/re-upload round-trip of the catalogue's
|
||||
longest audio files.
|
||||
|
||||
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.)
|
||||
**Reuse point — resolved (see §7.3).** The existing player-bar waveform preprocessor is the *same
|
||||
code path* parameterized by resolution, not a copy. `WaveformProfileService.ComputeAndStoreAsync`
|
||||
currently takes its resolution from injected `WaveformProfileOptions` (`BucketCount = 512`); the
|
||||
refactor is a per-call resolution/profile parameter plus a distinct entry-key/vault target for the
|
||||
high-res datum — small, server-side, and part of Wave 2 alongside the trigger endpoint. (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
|
||||
`SessionMetadata.HeroImageEntryKey`. Flow: `POST api/release/{id}/session/hero-image` (multipart,
|
||||
resource-addressed — the release id rides the route, consistent with the waveform trigger's shape) →
|
||||
image vault write → `SessionMetadata.HeroImageEntryKey` set. Mirrors the existing cover-art
|
||||
`UploadImageAsync` + link-via-`UpdateAsync` pattern Phase 8 documented; the only difference is the
|
||||
target field.
|
||||
|
||||
@@ -283,10 +323,10 @@ 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`. |
|
||||
| `GET api/release?medium={cut\|session\|mix}&page=&pageSize=&sort=` | unauth (public reads) | Paginated releases of a medium, with the matching medium's metadata `Include`d via the projection map. The medium filter is additive — omitting it returns all releases. |
|
||||
| `GET api/release/{id}` | unauth | Single release with **both** metadata navs always-`Include`d (nulls for non-matching media). Feeds the public detail views. |
|
||||
| `POST api/release/{id}/session/hero-image` | ApiKey | Multipart hero-image upload → image vault → set `SessionMetadata.HeroImageEntryKey`. Resource-addressed: the release id is in the route. |
|
||||
| `POST api/release/{id}/mix/waveform` | ApiKey | **Server-side trigger, no body.** API fetches the mix audio from its own vault, computes the high-res waveform via the parameterized `WaveformProfileService`, stores the datum in the vault, sets `MixMetadata.WaveformEntryKey` (§3.4). |
|
||||
|
||||
**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`:
|
||||
@@ -302,10 +342,23 @@ Recommend a **new `api/release` family** rather than overloading `api/track/page
|
||||
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.
|
||||
**Read shape + extensibility — two reads, two strategies.**
|
||||
|
||||
- **`GET api/release/{id}`** — **always-`Include` both metadata navs.** The medium is unknown until
|
||||
the row is fetched, so a per-medium projection map here would either double-query or be bypassed.
|
||||
With exactly two 1:1 navs behind unique FK indexes, including both is cheap and naturally yields
|
||||
nulls for non-matching media — no per-medium branching, no map needed on the by-id read.
|
||||
- **`GET api/release?medium=`** — here the **per-medium projection map** earns its keep: it avoids
|
||||
joining every metadata table on every page query. The map carries a **dual responsibility**: (1)
|
||||
selecting the right `Include` per medium, and (2) acting as the **single enforcement point for
|
||||
the medium↔metadata correlation** — a metadata DTO is populated iff the medium matches (§2.4).
|
||||
That second responsibility is the reason the map must not be inlined into the controller. Not a
|
||||
hardcoded `if session … else if mix …` chain — though honesty demands noting a dictionary keyed
|
||||
by an enum *is* a switch, data-structured.
|
||||
|
||||
The extensibility guarantee, stated honestly, is **"one entry, one file"** — a future medium adds
|
||||
one declaration in one known place and the read *logic* is untouched — not "zero changes to the
|
||||
controller." Same Open/Closed discipline as the CMS cards.
|
||||
|
||||
---
|
||||
|
||||
@@ -335,6 +388,15 @@ or (b) hardcode ARCHIVE as a distinct popover component in `DeepDrftMenu` alongs
|
||||
gets it free) and keeps the menu data-driven. Mobile renders children as indented sub-links inside
|
||||
the existing hamburger panel.
|
||||
|
||||
**`Children` semantics — pinned down now, or the model grows warts:**
|
||||
|
||||
1. **Dual-role nodes.** ARCHIVE carries both a `Route` (`/archive`) *and* `Children`. The semantics,
|
||||
defined once: **desktop hover** opens the children popover; **desktop click** navigates to
|
||||
`/archive`; **mobile** renders the parent as a link with its children indented below it. Do not
|
||||
leave this to the implementer.
|
||||
2. **Depth cap: one level only.** Children do not themselves have children. A future need for
|
||||
deeper nesting is a redesign of the nav, not a recursion in the model.
|
||||
|
||||
### 5.2 CUTS — `/cuts`
|
||||
|
||||
Reuses the existing **`AlbumsView`** layout, filtered to `Medium == Cut`. Studio Singles / EPs /
|
||||
@@ -372,23 +434,45 @@ Session detail is hero-led; Mix detail is waveform-led. Three readings:
|
||||
Recommend (iii). It bounds the duplication while letting each medium own its distinctive
|
||||
above-the-fold without polluting the others.
|
||||
|
||||
**`ReleaseDetailScaffold` contract (written down, or a god-component grows here):**
|
||||
|
||||
- The scaffold owns exactly the **invariant trio: metadata block, play affordance, player wiring**.
|
||||
Nothing else.
|
||||
- *Every* per-medium variance rides a slot (`RenderFragment`) supplied by the page. Named slots are
|
||||
fine where genuinely needed (e.g. `BodyContent` for the Cut/Album multi-track listing); **a
|
||||
boolean layout parameter on the scaffold is a design failure — that variance belongs in a slot.**
|
||||
If the scaffold accumulates flags (`ShowTrackList`, `HeroAboveMeta`, …), it has become option (ii)
|
||||
wearing a composition costume.
|
||||
|
||||
**`TrackDetail`'s fate — decided explicitly, not by silence.** `TrackDetail` is **refactored onto
|
||||
the scaffold in Wave 4** (recommended). It is the scaffold's extraction source, so this is nearly
|
||||
free — and it prevents two sources of detail-page truth (the existing track-cardinal `TrackDetail`
|
||||
that `AlbumsView`/`/cuts` cards land on vs. the scaffold-composed Session/Mix details), which *One
|
||||
source, multiple views* forbids. If Wave 4 pressure forces a deferral, the fork must be recorded as
|
||||
deliberate debt with a retirement note; silence here guarantees the fork happens by accident.
|
||||
|
||||
### 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.
|
||||
- **Detail (`/mixes/{id}`):** the page's defining visual is a **`MixWaveformVisualizer`** component
|
||||
fed by the preprocessed waveform datum from `MixMetadata.WaveformEntryKey`, rendered as the
|
||||
**full-page background** of the detail page (see the rendering note below — a background
|
||||
treatment, not a hero-slot block). Designed as a **named, reusable component** (the brief is
|
||||
explicit) so it can be reused — e.g., a future 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.
|
||||
- **Rendering — its own visual language, full-page background (Daniel's condition).**
|
||||
`MixWaveformVisualizer` is a dedicated **full-page background visual** for the Mix detail page —
|
||||
it is explicitly **not** a player-bar component. The `SpectrumVisualizer` / `LevelMeterFab`
|
||||
peak-bar idiom is **reserved for the player bar only**; this component must **not** borrow it.
|
||||
Instead it **establishes its own visual language**: a high-resolution, sophisticated visualizer
|
||||
rendered as the page background behind the detail content. The two components are siblings in
|
||||
subject matter (waveforms) but carry **entirely separate design treatments** — the player-bar
|
||||
peek and the Mix background share a data pipeline (§3.4), never a look.
|
||||
- **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
|
||||
@@ -417,19 +501,27 @@ above-the-fold without polluting the others.
|
||||
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.
|
||||
(resolved — vault blob, §7.1) and hero/cover images live vault-side; medium discriminator +
|
||||
metadata-table rows live SQL-side.
|
||||
- The existing low-res waveform pipeline is **server-side**: `POST api/track/{trackId}/waveform`
|
||||
takes no body — `TrackController` pulls the audio from its own vault and computes via
|
||||
`WaveformProfileService.ComputeAndStoreAsync` (resolution from injected `WaveformProfileOptions`,
|
||||
`BucketCount = 512`). The Mix trigger (§3.4) mirrors this idiom.
|
||||
|
||||
---
|
||||
|
||||
## 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.*
|
||||
1. **Resolved: Waveform storage shape (§2.3).** Vault blob + `WaveformEntryKey` confirmed. Settled
|
||||
by the SOLID-review F1 endpoint redesign: the server-side trigger (§3.4) computes and stores the
|
||||
datum in the vault and records only the entry key in SQL — a JSON column never enters the flow.
|
||||
Wave 1 adds only the SQL column; the vault write rides the existing vault abstraction server-side.
|
||||
2. **Resolved: Genre browse fate (§3.1).** Daniel's decision: the Genre tab slot is taken by Release Archive (Wave 3A as specced); the existing genre browse functionality is deprioritized and stays route-reachable as-is — no active development, no retirement. The team should not remove it.
|
||||
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.*
|
||||
3. **Resolved: Waveform preprocessor reuse (§3.4).** One server-side parameterized pipeline — not a
|
||||
CMS-side copy. Settled by the SOLID-review F1 endpoint redesign: `WaveformProfileService` gains a
|
||||
per-call resolution/profile parameter (today it reads `WaveformProfileOptions.BucketCount = 512`)
|
||||
plus a distinct entry-key/vault target for the high-res datum. The refactor lands in Wave 2
|
||||
alongside the trigger endpoint, not Wave 3.
|
||||
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.*
|
||||
@@ -445,17 +537,46 @@ above-the-fold without polluting the others.
|
||||
|
||||
## 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:
|
||||
The phase is designed so a **fourth medium** (say, "Video," already hinted in `PLAN.md §3.1`) is an
|
||||
**addition, not a modification**. Two honest lists — what never changes, and what the addition
|
||||
actually 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.
|
||||
**(a) What is never modified — the true Open/Closed payoff:**
|
||||
|
||||
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."
|
||||
- the base `ReleaseEntity` shape,
|
||||
- the other media's metadata tables,
|
||||
- the existing browse grids,
|
||||
- the existing endpoints (read *logic* included — the list read grows one projection entry, §4),
|
||||
- the existing detail scaffolding (`ReleaseDetailScaffold` and the other media's detail pages).
|
||||
|
||||
**(b) The full additive surface — each artifact with its single known location:**
|
||||
|
||||
1. one `ReleaseMedium` enum value (`DeepDrftModels/Enums/ReleaseMedium.cs`);
|
||||
2. *if* the medium needs extra data: one metadata entity + EF config + migration;
|
||||
3. *if* extra data: one metadata DTO;
|
||||
4. one display-metadata entry in the CMS card lookup (one location in `DeepDrftManager`);
|
||||
5. one nav `Children` entry in `Pages.cs` (`DeepDrftPublic.Client/Layout/`);
|
||||
6. one medium→route mapping entry (one location);
|
||||
7. one per-medium form-section component, registered at the single `MediumFields` dispatch point
|
||||
(§3.3) — one new component + one dispatch entry, not five form edits;
|
||||
8. one CMS browser component (or a grid-parameterization entry where the shared grid fits, §3.2);
|
||||
9. a public gallery page + detail page + routes (new routable components — Blazor routes are
|
||||
attribute-based, so a new medium means new pages);
|
||||
10. one projection entry in the `api/release?medium=` map (§4);
|
||||
11. its own hero-slot / background renderer composed onto the detail scaffold (§5.3, §5.4).
|
||||
|
||||
That is more than the "five items" an earlier draft claimed — the additive surface is real and
|
||||
recurring, and understating it is precisely the "extensible on paper" failure this section exists
|
||||
to warn against. The claim worth defending is (a): a new medium modifies **zero existing tables,
|
||||
zero existing media's components, zero existing endpoints**. Where the design admits a mapping
|
||||
(card → browser, medium → projection, medium → form section, medium → route, medium → detail
|
||||
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."
|
||||
|
||||
**LSP rationale — why there is no type hierarchy to violate.** Rejecting option (B) in §1 (EF TPH
|
||||
subclasses) was the correct Liskov move, not merely a storage-layer call. `SessionRelease` /
|
||||
`MixRelease` subtypes would promise a substitutability that is a lie — most code paths ("give me
|
||||
releases") don't care about medium, and the ones that do would downcast. In this design there is
|
||||
**no inheritance to downcast through**: medium variance rides *data* (the metadata rows) and
|
||||
*composition* (the detail slots), not subtypes.
|
||||
|
||||
Reference in New Issue
Block a user