# PLAN.md — DeepDrftHome forward roadmap Forward-looking roadmap. Sits alongside `CONTEXT.md` (architecture orientation) and `COMPLETED.md` (history). Per `CONTEXT.md §6`, items move from here to `COMPLETED.md` when work lands; do not delete completed entries. Organised by **theme**, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so. --- ## 0. Baseline — what just landed A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on `dev`. The remainder of this plan assumes that baseline. In summary the audit-pass fixed: - **Index concurrency** — `VaultIndexDirectory` no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers. - **Repository semantics** — `TrackRepository.Update` now fails-fast when an `Id` is not found instead of silently issuing an `INSERT`. - **Streaming Criticals** — concurrent-seek race in the client, dirty trailing bytes leaking out of the `ArrayPool`-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header. - **17 design and streaming Majors/Minors** across all eight projects — format-validation alignment between processor/offset/decoder, `IAsyncDisposable` on the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings. What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in `TODO-V2.md` that did not land are **deferred as features, not bugs** — they are captured below under Phase 1. --- ## Phase 1 — Streaming features deferred from the audit These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact. ### 1.3 Preload / prefetch of the next track - **What:** No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch. - **Why it matters:** Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight. - **Shape:** A second `HttpClient` request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged `StreamDecoder` instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. - **Prerequisite:** Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in `IPlayerService` or a passive "what was the next row in the gallery" inference. - **Open question:** Does a queue model belong in `IPlayerService`, or is the player a single-slot device that a future `PlaylistService` orchestrates above? Worth a design note before implementation. Capture in product notes when picked up. ### 1.4 Crossfade - **What:** Smooth A→B transition with overlapping fade-out / fade-in. - **Why it matters:** DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track." - **Shape:** Architecturally two simultaneous `PlaybackScheduler` instances suffice — each owns its own gain node, crossfaded via `GainNode.gain.linearRampToValueAtTime`. The wiring is the work, not the audio graph itself. - **Prerequisite:** **1.3 (Preload)** — there is nothing to fade *into* without prefetch. ### 1.5 Gapless playback - **What:** Eliminate the inter-track silence that exists today. - **Why it matters:** Important for live-set rips, mix tapes, anything authored to flow continuously. - **Shape:** The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With `PlaybackScheduler`'s existing 500 ms lookahead this is mechanically achievable — the next track's first `AudioBufferSourceNode.start(t)` is set to the previous track's end time. - **Prerequisite:** **1.3 (Preload)**. Also needs to play nicely with **1.2** because gapless across formats is hard (encoder padding/priming on MP3 in particular). - **Constraint:** Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands. ### 1.6 Track-skip on error - **What:** A failed `processStreamingChunk` aborts the entire load with no recovery path. - **Why it matters:** One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region. - **Shape:** Two-level response. - Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists. - Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once **1.2** lands. ### 1.7 Safari compatibility - **What:** Two known Safari edge cases. - `webkitAudioContext.close()` is async-but-not-Promise on older Safari (≤ ~14); `await` resolves immediately and the next `initialize()` can run against a not-yet-closed context. - iOS Safari < 15 had streaming-fetch quirks; `HttpCompletionOption.ResponseHeadersRead` behaviour is not guaranteed there. - **Why it matters:** Real listener share. iOS in particular is a primary listening surface for music. - **Shape:** For the `close()` race — detect `webkitAudioContext` and poll `state === "closed"` with a short timeout instead of trusting the `await`. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency. - **Open question:** What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely. --- ## Phase 2 — Product surface: gallery, browsing, ingestion These follow from `CONTEXT.md §5`. Direction is strongly implied but no specific UI has been committed. --- ## Phase 6 — CMS Enhancements (Completed) See `COMPLETED.md` for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer) which landed on dev in June 2026. --- ### 6.2 Card-contextual filtering of the Tracks page — `[superseded by §8]` - **What:** Make the Album and Genre dashboard cards navigate into a *filtered* `/tracks` view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table. - **Why:** Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist. - **Why deferred:** The dashboard cards aggregate *across all* albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public `AlbumsView`/`GenresView`, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The `GET api/track/page` endpoint already accepts `album=` and `genre=` query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in `TrackList`. - **Superseded:** **§8 (CMS Track Browser)** builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode *are* the CMS analogue of `AlbumsView`/`GenresView`, and the filter plumbing into `GetPagedAsync` is part of §8's data contract. This item folds into §8; do not implement it separately. --- ## Phase 3 — New content kinds ### 3.1 Live / session content - **What:** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these. - **Why it matters:** Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output. - **Shape:** Speculative; no commitment yet. - Likely new entity table(s) sibling to `TrackEntity` (`SessionEntity`, `VideoEntity`?) — or a polymorphic `MediaEntity` with discriminator. The choice affects how much code in `TrackService` / `TrackController` can be reused. - New vault type(s). `MediaVaultType.Media` exists and is the obvious home for video; sessions are probably still `Audio`. - New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder). - **Prerequisite:** Probably **2.1** (vault wiring proof) and a decision on the entity model before any code lands. - **`[speculative]`** — direction inferred from home-page copy, not a Daniel-confirmed commitment. --- ## Phase 4 — Infrastructure / delivery ### 4.3 Dual-write rollback / dead-letter log - **What:** If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists. - **Why it matters:** A latent data-integrity issue. Materially riskier once web upload (2.4) exists. - **Shape:** Audit suggested a `DeadLetterLog` recording orphaned `entryKey`s for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us). - **Prerequisite:** None. Worth landing alongside or just before 2.4. --- ## Phase 5 — Documentation backlog ### 5.1 Folder-level CLAUDE.md sweep - **What:** Eight folder-level `CLAUDE.md` files need writing/rewriting per the brief in `DOC_PLAN.md`. Five are rewrites (drift from the `.NET 10` upgrade and structural moves); three are new (`DeepDrftWeb.Services`, `DeepDrftContent.Services` — the two libraries where most domain logic now lives — plus the open question on `DeepDrftContent.Services/FileDatabase/README.md`). - **Why it matters:** The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming `.NET 9`, referencing `MediaPath` that has been `EntryKey` for two migrations, describing a `FileDatabase/` tree inside `DeepDrftContent` that has moved out, and missing entirely for the two `*.Services` libraries. - **Shape:** Doc-keeper executes against `DOC_PLAN.md`. Order of operations and the per-folder briefs are already specified there. - **Prerequisite:** None. Can run fully in parallel with any feature work. - **Constraint:** Wait on Daniel for the `DeepDrftContent.Services/FileDatabase/README.md` judgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision. --- ## Phase 7 — Shared UI Components Reusable presentational components in `DeepDrftShared.Client` (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose. --- ## Phase 8 — CMS Track Browser Three browse modes for the CMS `/tracks` page — **Track**, **Album**, **Genre** — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the `TracksViewModel` pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes *are* the intermediate browse surface that item was waiting on. Full spec: `product-notes/phase-8-cms-track-browser.md` (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions). **§8.0 landed on 2026-06-11** — a breaking `TrackEntity` normalization has been completed and is stable on dev. §8.1–§8.5 are now unblocked. The Waveform Pre-Processing tab is **removed**, folded into an in-grid status column + per-row/page-level generate actions (see §8.2). --- A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now. - **Identity / accounts.** Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. `[speculative]` until Daniel signals interest. - **`ITrackService` interface.** Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase. - **Test coverage outside FileDatabase.** Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 1–4 land, test scope should expand — at minimum `WavOffsetService`, `AudioProcessor`, `TrackService` (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work. --- ## 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`; `ReleaseDto.ReleaseType` is **nullable**, nulled at the single entity→DTO mapping point for non-`Cut` so one producer enforces and no consumer needs the rule), **not** a DB constraint — **by choice, not necessity**: EF Core supports check constraints first-class (`HasCheckConstraint`, versioned in migrations, Npgsql-supported), but the invariant is advisory ("meaningless," not "invalid") and the read model enforces it at one point. The column stays on `ReleaseEntity` as a **named exception** to the metadata-table pattern: a `CutMetadata` table was considered and rejected because the `/cuts` hot path reads `ReleaseType` on every card and Phase 8 §8.0 just landed the column (see spec §1). Future media must not copy this — the default remains the metadata table. 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; both Wave 4 **build and acceptance** are independent of Wave 3 (CMS) — the body-less `POST api/release/{id}/mix/waveform` trigger (9.2.B) can seed real waveform datum for acceptance testing without any CMS in existence, and hero images seed via a script against 9.2.B likewise. --- ### 9.5 Wave 5 — Gap Cleanup Waves 1–4 are on `dev`. This wave fixes functional gaps discovered in the landed code: one disclosed by the Wave 3 engineer (medium is never written through the upload path), two structural issues flagged by review (fragile track resolution in the detail VM, browser duplication), and one nav gap (`/tracks` is unreachable from the public menu). Items are ordered: **A–B are blockers** (data correctness); **C–E are correctness/nav gaps**; **F is a structural debt item** worth landing when the browsers next need editing. **9.5.A — Medium write path: `POST api/track/upload`** - **What:** The `POST api/track/upload` endpoint accepts no `medium` form field. `CmsTrackService.UploadTrackAsync` already sends `medium` in the multipart body (a forward-compatible no-op left by the Wave 3 engineer), but the API ignores it. Every uploaded release is created with `Medium = Cut` regardless of the CMS form selection. Sessions and Mixes uploaded through the CMS are silently mis-typed at the database level. - **Why:** This is the primary functional gap of the phase. A mix uploaded as `Cut` does not appear in the `/mixes` browser, does not trigger waveform generation on the correct release, and the public `/mixes/{id}` detail page will never find it. The bug is silent — no error surfaces; the track uploads cleanly into the wrong category. - **Shape:** Three layers, each minimal: 1. **`TrackController.UploadTrack`** — add `[FromForm] string? medium` parameter. Parse it with `Enum.TryParse` (same defensive pattern as `releaseType`, defaulting to `Cut` with a logged warning on unrecognised values). Pass the parsed value into `UnifiedTrackService.UploadAsync`. 2. **`UnifiedTrackService.UploadAsync`** — add `ReleaseMedium medium` parameter. Include it in the `ReleaseDto` passed to `FindOrCreateRelease` (the DTO already has the `Medium` field; it is simply not populated today). 3. **`FindOrCreateRelease` find-path:** When the release *already exists*, the returned row's `Medium` is not updated to match the upload's intent. This is correct behaviour for the first track — the release was created with the right medium. It is potentially wrong for subsequent tracks uploaded to the same release with a corrected medium. No change required here: medium is a release-level property, and the first upload is authoritative. Document this explicitly in the service comment so future engineers do not try to "fix" it. - **Acceptance criteria:** A Session upload from the CMS creates (or links to) a release with `Medium == Session`; a Mix upload creates a release with `Medium == Mix`; a Cut upload is unchanged. The `GET api/release?medium=session` endpoint returns the Session release immediately after upload with no manual migration. - **Open question:** Should the upload path *update* an existing release's medium when it differs? Recommend no — a release's medium is set on creation and should not silently change on a subsequent track add. If an admin needs to change a release's medium, that is an edit operation (9.5.B). Capture this as a comment in the service, not a policy decision to re-open here. **9.5.B — Medium write path: `PUT api/track/meta`** - **What:** `UpdateTrackMetadataRequest` carries no `Medium` field. `PUT api/track/meta/{id}` can update `ReleaseType` on a release but cannot change `Medium`. `CmsTrackService.UpdateAsync` sends no `medium` field. An admin who uploads a Session as `Cut` (due to the pre-9.5.A bug, or a future form mistake) has no way to correct the medium through the CMS after the fact. - **Why:** Without an edit path, the only remediation is a direct DB update or a delete-and-re-upload. Both are bad. The edit path should be complete. - **Shape:** 1. **`UpdateTrackMetadataRequest`** — add `ReleaseMedium? Medium` (nullable: null = no change, matching the `ReleaseType?` pattern already on the request). 2. **`TrackController.UpdateMeta`** — apply `request.Medium` to `release.Medium` when non-null, alongside the existing `ReleaseType` conditional (the same six-line pattern at line 394–395 of the controller). 3. **`CmsTrackService.UpdateAsync`** — add `ReleaseMedium? medium = null` parameter, include in the JSON body. 4. **`ICmsTrackService`** — update the interface signature to match. 5. **`TrackEdit.razor` / `BatchEdit.razor`** — wire the `MediumFields` selector (already present for upload via `BatchUpload`) into the edit submit path, passing the selected medium. - **Acceptance criteria:** An admin can open an existing release in `TrackEdit` or `BatchEdit`, change the medium selector, submit, and the release's `Medium` column updates in the DB. The browsers (`CmsAlbumBrowser`, `CmsSessionBrowser`, `CmsMixBrowser`) reflect the new medium after the edit. - **Constraint:** The `ReleaseType`-only-for-`Cut` invariant: when medium changes away from `Cut`, the controller should null (or ignore) `ReleaseType` on the release — the same enforcement the `TrackConverter` already applies on the read path. Mirror that logic on the write path: if `request.Medium` is non-null and not `Cut`, reset `release.ReleaseType = ReleaseType.Single` (the DB-level default) rather than leaving a stale studio-format value. **9.5.C — `ReleaseDetailViewModel`: replace fragile album-title track resolution** - **What:** `ReleaseDetailViewModel.Load` resolves the playable track for a Session or Mix detail page by calling `_trackData.GetPage(pageNumber: 1, pageSize: 1, album: release.Title)`. This is a string join on album title. If two releases share the same title (different artists — e.g., both have an untitled mix), the wrong track is returned. More fundamentally, filtering by album title relies on the `Release.Title` matching what was stored as the album string at upload time — a join that is fragile once releases can be renamed via the edit path (9.5.B). - **Why:** The correct join is by `releaseId`, not album title. The track-page endpoint already supports `album=` filtering; it needs an additional `releaseId=` filter, or the public API needs a `GET api/track/by-release/{releaseId}` endpoint. This is a correctness issue, not a cosmetic one — a collision silently plays the wrong track. - **Shape (recommended):** Add a `releaseId` query parameter to `GET api/track/page` in `TrackController` and thread it through `ITrackService.GetPaged` → `TrackRepository.GetPagedFilteredAsync` as an additional `WHERE release_id = @releaseId` predicate. `TrackFilter` gains a `long? ReleaseId` field. `ReleaseDetailViewModel.Load` then calls `GetPage(pageNumber: 1, pageSize: 1, releaseId: release.Id)` — an exact join, no title string. The public `IReleaseDataService` and `ReleaseClientDataService` do not need changes if the track page is called directly via `ITrackDataService`. - **Acceptance criteria:** `/sessions/{id}` and `/mixes/{id}` resolve their playable track by `releaseId`, not by album title string. Two releases with identical titles return their own correct tracks on their respective detail pages. - **Open question:** Should `TrackFilter.ReleaseId` be exposed on the public unauthenticated `GET api/track/page` endpoint? Yes — it is a read-only filter on public data, same posture as `album=` and `genre=`. No auth change. **9.5.D — Public nav: `/tracks` route unreachable** - **What:** `Pages.MenuPages` (the public nav model) contains ARCHIVE (with sub-items /cuts, /sessions, /mixes) and Genres. `/tracks` (the original track gallery at `TracksView.razor`) is not in the nav. The route is still live — typing `/tracks` in the address bar works — but there is no menu entry, no link from any existing page, and no redirect from any of the new medium surfaces. - **Why it matters:** The track gallery is a useful surface (flat cross-medium search, grid/list toggle, genre/album filter). Removing it from the nav without a replacement or deliberate deprecation is a nav gap. A listener who does not know about `/cuts` has no way to discover the flat track list. - **Shape (three options — pick one):** - **Option A (recommended): Add `/tracks` back to the nav.** Add a "Tracks" entry (flat, no children) to `Pages.MenuPages` alongside ARCHIVE and Genres. Zero risk; the page exists and works. Honest about what the site offers. - **Option B: Retire `/tracks` explicitly.** Add a redirect from `/tracks` → `/cuts` (or `/archive`) and remove `TracksView.razor`. Requires confirming that `/cuts` is a complete replacement (it is not — `/cuts` shows only Cut releases; `/tracks` is a flat cross-medium list). Not recommended unless Daniel confirms the gallery is intentionally retired. - **Option C: Make ARCHIVE the gallery.** Repurpose `/archive` from the current three-card overview to the flat track gallery. Feels wrong — `/archive` is already a meaningful overview page, not a gallery. - **Recommendation:** Option A. The track gallery is valuable and distinct from the medium-specific browsers. Add "Tracks" to `Pages.MenuPages`. If Daniel later wants to retire the gallery, that is a separate explicit decision with a redirect. Do not silently leave a useful route off the nav. - **Acceptance criteria:** `/tracks` appears in the public navigation menu. Desktop and mobile nav both link to it. Existing functionality of `TracksView` is unchanged. **9.5.E — `CmsSessionBrowser` and `CmsMixBrowser`: missing Edit row action** - **What:** The Wave 3 spec for 9.3.B says "row Edit + hero-image management" for the Session browser, and the Mix browser should similarly have an edit affordance. The landed `CmsSessionBrowser` and `CmsMixBrowser` provide the medium-specific action (hero upload / waveform generate) but no Edit button linking to the standard release edit page (`/tracks/album/{name}/edit` via `BatchEdit`). - **Why:** Without the Edit button, an admin cannot rename a session, change its artist, update its genre, or swap its cover art from the browser. The only path is navigating to `/tracks`, finding the session track, and editing it from there — which itself is now off the nav (9.5.D). - **Shape:** Add a `MudButton` (or `MudIconButton`) per row linking to `/tracks/album/@Uri.EscapeDataString(context.Release.Title)/edit` in both browsers, matching the `CmsAlbumBrowser` pattern. No new components or endpoints. - **Acceptance criteria:** Each row in `CmsSessionBrowser` and `CmsMixBrowser` has an Edit button that navigates to `BatchEdit` for that release. The edit page loads the release's tracks and release-level fields correctly. **9.5.F — `CmsSessionBrowser` / `CmsMixBrowser` structural duplication (DRY debt)** - **What:** Both browsers share an identical structural skeleton: a `LoadAsync` method with `_loading` / `_rows` fields, an `OnInitializedAsync` → `LoadAsync` call, a `ThumbUrl` static helper, snackbar error handling, and a `MudTable` with cover-thumbnail + title + artist columns. Only the per-row action column and the row model differ. This is copy-paste, not composition. The Phase 9 intro promises "a new medium is one entry, one file" — with this structure, a new medium browser is instead two files of boilerplate plus one file of new logic. - **Why:** Manageable now at three media, but violates the open/closed discipline the phase established. The right fix is a `MediumBrowserBase` abstract base (or a parameterized `CmsMediumBrowser` component with an action-column slot), reducing each browser to its medium-specific action markup only. - **Shape:** Extract a `CmsMediumBrowserBase` class (analogous to `MediumBrowseBase` on the public site) carrying: `_loading`, `_rows`, `OnInitializedAsync`, `LoadAsync`, `ThumbUrl`. Subclasses supply the `ReleaseMedium` and the per-row action column. The table structure (cover, title, artist, actions) is rendered in the base or via a shared `CmsMediumTable` Razor component with an `ActionContent` `RenderFragment` parameter. A new medium browser is then a subclass that overrides the medium enum and implements the action fragment. - **Acceptance criteria:** `CmsSessionBrowser` and `CmsMixBrowser` no longer duplicate `LoadAsync` / `ThumbUrl` / the error-snackbar pattern. A third medium browser (hypothetical) would require only the medium-specific action markup, with zero structural boilerplate. - **Note:** This is structural debt, not a functional gap. Mark `[nice-to-have]` if Wave 5 is time-boxed. The functional items (A–E) are the priority; F can defer to Wave 6 if needed. **Dependency summary for Wave 5:** A and B are independent of each other (parallel tracks) and are the highest priority — both are data-correctness blockers for Session/Mix releases created since Wave 3 landed. C depends on A and B being stable (so the detail VM resolves tracks for correctly-typed releases). D and E are independent nav/UI fixes. F is independent structural debt. --- --- ## 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. - **When something lands, move it to `COMPLETED.md`** rather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome. - **Mark genuinely uncertain items `[speculative]`** so future readers can tell what is direction vs. commitment. - **Open questions belong in the item that raises them**, not in a separate "questions" list — they expire when the item does.