69 Commits

Author SHA1 Message Date
daniel 05130aaed2 docs: record Wave 8 tracks 8.C and 8.E landed; Phase 9 gate met
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m10s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m25s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m3s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m30s
2026-06-13 22:43:53 -04:00
daniel 03c96c621b Merge p9-w8-8e-add-track-buttons into dev (8.E: medium-aware Add Track on Release Archive tabs) 2026-06-13 22:38:36 -04:00
daniel c9457ae21b Merge p9-w8-8c-medium-grid-parity into dev (8.C: per-medium tab grids to ALL-tab parity) 2026-06-13 22:38:19 -04:00
daniel c6ef641ab9 feat(cms): medium-aware Add Track on Release Archive tabs (8.E)
Add Track now appears on every Release Archive tab and pre-selects the upload form's medium via ?medium=… (ALL→Cut); the selector stays user-changeable on landing.
2026-06-13 22:33:33 -04:00
daniel 3ef98aa3ff feat(cms): bring per-medium tab grids to ALL-tab parity (§8.C)
Render the rich CmsAlbumBrowser filtered per medium in the CUTS/SESSIONS/MIXES
tabs via an optional RowActions slot; retire the thin CmsMediumTable. Session
hero and Mix waveform actions preserved; ALL tab and TrackList unchanged.
2026-06-13 22:33:31 -04:00
daniel 4b9e6531fd docs: record Wave 8 track 8.A landed 2026-06-13 22:16:05 -04:00
daniel f9c483bbad Merge p9-w8-8a-tab-strip into dev (8.A: CMS Release Archive medium tab strip) 2026-06-13 22:09:19 -04:00
daniel 20084ace4f feat(cms): Release Archive medium tab strip (ALL · CUTS · SESSIONS · MIXES), retire navigate-away cards
Replace the navigate-away ReleaseArchiveBrowser cards and the redundant top-level Releases
toggle with an in-page MudTabs strip under the Releases mode: ALL (CmsAllReleasesGrid) plus
one enum-driven tab per ReleaseMedium. Sessions/Mixes browsers gain an Embedded flag that
suppresses standalone page chrome when hosted as tab content; CmsCutBrowser is the new
Cut-filtered grid. /tracks/sessions, /tracks/mixes, /tracks/archive stay reachable by URL.
2026-06-13 22:02:28 -04:00
daniel 3f1230fd2d docs: record Wave 8 tracks 8.B and 8.I landed 2026-06-13 21:38:00 -04:00
daniel 9e7755812f Merge p9-w8-8i-nav-slim into dev (8.I: slim public nav, inline medium links, drop GENRES/Tracks) 2026-06-13 21:30:39 -04:00
daniel 314e7b1f34 Merge p9-w8-8b-all-tab-grid into dev (8.B: embeddable ALL-tab all-releases grid) 2026-06-13 21:30:26 -04:00
daniel 743c2c3d02 feat(public-nav): slim appbar to ARCHIVE + inline CUTS/SESSIONS/MIXES, drop GENRES and Tracks (8.I)
Desktop flattens the ARCHIVE popover into inline appbar links above the medium
breakpoint; mobile keeps the indented sub-list under ARCHIVE. GENRES and /tracks
removed from nav only — routes (GenresView, TracksView) remain reachable by URL.
Retires the now-dead desktop hover-popover and its 8.J collapse-state machinery
(mobile drawer still dismisses on click).
2026-06-13 21:26:44 -04:00
daniel e78a61c3b1 feat(cms): extract all-releases grid as embeddable ALL-tab component (9.8.B)
CmsAllReleasesGrid self-loads the cross-medium release list so 8.A can host it as the ALL tab with no VM plumbing; TrackList's Albums mode renders it now. Preserves sort/delete/expand/edit and the 8.D Type chip.
2026-06-13 21:26:43 -04:00
daniel 2991d9ec5d docs: record Wave 8 tracks 8.F and 8.H landed 2026-06-13 21:11:23 -04:00
daniel c748d901d3 Merge p9-w8-8h-archive-browser into dev (8.H: release-cardinal searchable /archive browser) 2026-06-13 21:00:37 -04:00
daniel 1beefe4515 Merge p9-w8-8f-session-hero-form into dev (8.F: Session hero image in upload form) 2026-06-13 21:00:29 -04:00
daniel 62dd9d5c03 fix(cms): gate Session hero input to upload path; warn (not error) on missing hero
Edit forms (BatchEdit/TrackEdit/TrackNew) show the guidance alert instead of an
inert picker, via an AllowHeroUpload flag. Missing-hero nudge is Severity.Warning;
null-ReleaseId hero drop is now logged.
2026-06-13 20:55:34 -04:00
daniel 737c423d9c feat: replace /archive with release-cardinal searchable browser (Phase 9 §8.H)
Retire the three-card overview for a search + medium + genre browser over all
releases. Adds q/genre filter params to the api/release paged read path,
mirroring the existing api/track/page TrackFilter pattern.
2026-06-13 20:47:50 -04:00
daniel 4701804594 feat(cms): compose Session hero image into the upload form (8.F)
Session upload now carries a deferred hero-image input; the submit handler
creates the release then POSTs the held hero to the existing resource-addressed
endpoint. Hero is optional with a non-blocking warn-if-missing gate. The
per-row hero upload in CmsSessionBrowser remains the replace/correct path.
2026-06-13 20:46:46 -04:00
daniel 18f4b596f2 docs: record Wave 8 tracks 8.D/8.G/8.J/8.L landed 2026-06-13 20:18:04 -04:00
daniel eeab0a1c4c Merge p9-w8-8j-popover-dismiss into dev (8.J: close ARCHIVE dropdown on child click) 2026-06-13 20:08:03 -04:00
daniel f44c270b9f Merge p9-w8-8l-name-collapse into dev (8.L: collapse release/track name for single-track media) 2026-06-13 20:07:52 -04:00
daniel 208db33927 Merge p9-w8-8g-release-name-label into dev (8.G: Album Name -> Release Name) 2026-06-13 20:07:46 -04:00
daniel 97686c2a16 Merge p9-w8-8d-type-chip into dev (8.D: Type chip Session/DJ Mix for non-Cuts) 2026-06-13 20:07:41 -04:00
daniel 86999cb94e fix(nav): per-parent dropdown collapsed state; reset on focusout
HashSet<string> _collapsedDropdowns replaces single bool so each parent
tracks its own dismiss state independently. onfocusout added alongside
onmouseleave so keyboard users get the dropdown re-enabled without a
mouse pass after Enter-activating a child link.
2026-06-13 20:00:20 -04:00
daniel 1b37a637e5 8.L: collapse release/track name for single-track media (Session, Mix)
BatchTrackDetail gains ShowTrackName parameter (default true); BatchUpload removes the
Track Name input on the single-track path; BatchEdit suppresses it there too. Both sync
_tracks[0].TrackName = _albumName on submit/save so names can never diverge. Cut path
unchanged.
2026-06-13 19:46:05 -04:00
daniel 2bd9aa7b74 fix(cms): rename "Album Name" label to "Release Name" across release header form
Covers AlbumHeaderFields MudTextField label + RequiredError, and the matching
code-side validation messages in BatchEdit and BatchUpload for consistency.
2026-06-13 19:45:55 -04:00
daniel c44117ccc5 fix(8.J): close ARCHIVE dropdown on child link click
Add dd-nav-item-collapsed CSS class toggled on child click to override the
:hover/:focus-within show rules. Cleared on mouseleave so hover-to-open
works normally on the next pass. Mirrors the existing CloseMobileMenu pattern.
2026-06-13 19:45:55 -04:00
daniel bc5d7f52b8 fix: Type chip in releases grid shows "Session"/"DJ Mix" for non-Cut media
Cut rows continue to show ReleaseType (Single/EP/Album). Session/Mix rows
now read from a MediumTypeLabels dictionary so a future medium needs only
one new entry, no markup change.
2026-06-13 19:45:26 -04:00
daniel add43c5a7d docs: split Wave 8 form work into 8.L name-collapse + 8.M legacy-form retirement 2026-06-13 19:37:53 -04:00
daniel 2f7af6d6d2 docs: resolve Wave 8 open questions, add 8.L name consolidation, finalize 8.K visualizer design 2026-06-13 19:18:37 -04:00
daniel fccace1381 docs: spec Phase 9 Wave 8 remediation + Mix Visualizer interview set 2026-06-13 17:02:53 -04:00
daniel c83b06aaee docs: reconcile DeepDrftAPI CLAUDE.md endpoint surface to Phase 9 (release family, track/page unauth, medium fields) 2026-06-13 16:22:45 -04:00
daniel 77a9eb1158 Merge p9-api-http-smokes into dev (Phase 9 API .http smoke file) 2026-06-13 16:12:21 -04:00
daniel f6b7fa2df5 feat: add Phase 9 API smoke tests (.http file) 2026-06-13 16:09:42 -04:00
daniel 2f565deb8f Merge p9-w7-cardinality-invariant into dev (9.7 per-medium cardinality invariant) 2026-06-13 15:27:33 -04:00
daniel 26246b5d65 docs: Phase 9 Wave 7 landed — move 9.7 from PLAN to COMPLETED 2026-06-13 14:28:02 -04:00
daniel b893ca84de Enforce per-medium track cardinality in the upload service via MediumRules
Promote the Session/Mix single-track rule from a CMS-form convention to a
domain invariant: declare cardinality as data in MediumRules, enforce it in
UnifiedTrackService before the vault write (no orphan), return 409, and read
the same rule in the batch-form collapse.
2026-06-13 14:12:01 -04:00
daniel 6f42464294 docs: Phase 9 Wave 6 landed — move 9.6 from PLAN to COMPLETED 2026-06-13 13:51:59 -04:00
daniel 6e0da7a486 Merge p9-w6-t2-batchedit-collapse into dev (9.6.B) 2026-06-13 13:23:49 -04:00
daniel 79c4e1e584 Merge p9-w6-t1-home-card-links into dev (9.6.A) 2026-06-13 13:23:35 -04:00
daniel 0371bcd15e docs: spec Phase 9 Wave 7 — per-medium track-cardinality domain invariant 2026-06-13 13:17:33 -04:00
daniel 9122cfee6e fix: collapse Session/Mix track list on load in BatchEdit (load-path parity with OnMediumChanged) 2026-06-13 13:17:05 -04:00
daniel bcfcc91618 wire medium cards to routes: Studio->/cuts, Live->/sessions, DJ Mix->/mixes 2026-06-13 12:55:02 -04:00
daniel fdc0208339 Collapse BatchEdit to single-track form for Session/Mix media
Mirror BatchUpload.OnMediumChanged: switching to Session/Mix trims the
track list to one row and hides the add-track affordance, enforcing the
§9.3 single-track invariant on the edit path. Cut releases unchanged.
2026-06-13 12:53:48 -04:00
daniel 1a08e3c787 docs: spec Phase 9 Wave 6 — gap closure (home-card destinations, BatchEdit single-track collapse) 2026-06-13 12:41:21 -04:00
daniel 7f575d1d75 Merge p9-w5-gitattributes-eol into dev 2026-06-13 12:22:07 -04:00
daniel 9a9adf5a57 gitattributes: pin parallax.js to LF to stop CRLF working-tree churn 2026-06-13 12:20:34 -04:00
daniel 31d7b20672 Merge p9-w5-t3-browser-edit-dry into dev (9.5.E/F) 2026-06-13 11:46:30 -04:00
daniel 3ab1d77ecb Merge p9-w5-t2-tracks-nav into dev (9.5.D) 2026-06-13 11:46:23 -04:00
daniel 0b989aa739 Merge p9-w5-t1-medium-write-path into dev (9.5.A/B/C) 2026-06-13 11:46:17 -04:00
daniel bb61cf4014 docs: Phase 9 Wave 5 landed — move 9.5 from PLAN to COMPLETED 2026-06-13 11:41:50 -04:00
daniel 8b62915083 Make release Medium writable via upload + meta-edit; resolve detail-page track by releaseId not album title 2026-06-13 11:34:45 -04:00
daniel a7e2335c20 Add Edit action to medium browsers; extract CmsMediumBrowserBase + CmsMediumTable
Session/Mix browsers share base (load/state/thumb) and a shared table shell carrying the per-row Edit link to BatchEdit; subclasses supply only their medium action.
2026-06-13 11:08:43 -04:00
daniel a40d82fa22 nav: add Tracks entry to public MenuPages 2026-06-13 11:05:52 -04:00
daniel ea018beb3e docs: spec Phase 9 Wave 5 — gap cleanup 2026-06-13 08:44:42 -04:00
daniel 412c0334c6 docs: Phase 9 Waves 3+4 landed — move 9.3 and 9.4 from PLAN to COMPLETED 2026-06-13 07:33:33 -04:00
daniel 3ea4eb143b Merge branch 'p9-w4-public' into dev 2026-06-13 07:13:30 -04:00
daniel d4d28fdb0e Merge branch 'p9-w3-cms' into dev 2026-06-13 07:13:28 -04:00
daniel 2f47efeb46 CMS Phase 9 Wave 3: Release Archive tab, medium selector, Session/Mix browsers
Renames Genre tab to Release Archive with switch-free medium card group
(Enum.GetValues-driven). Adds MediumFields single dispatch + CutFields/SessionFields/
MixFields per-medium sections embedded by all five upload/edit forms. BatchUpload
enforces single-track invariant for Session/Mix. Adds CmsSessionBrowser (hero-image
upload) and CmsMixBrowser (waveform status + per-row Generate trigger).
ICmsReleaseService/CmsReleaseService wraps api/release endpoints.
Note: medium selector is forward-compat only — API write path pending.
2026-06-12 23:07:15 -04:00
daniel af724ce570 Phase 9 Wave 4: ARCHIVE nav + Cuts/Sessions/Mixes pages + MixWaveformVisualizer
Replaces flat RELEASES/SESSIONS/MIXES nav with ARCHIVE dropdown (PageRoute.Children,
one-level cap, dual-role node). Adds /archive overview, /cuts (AlbumsView + medium
filter; /albums redirects), /sessions + /sessions/{id} (hero-dominant), /mixes +
/mixes/{id} (MixWaveformVisualizer full-page background). Extracts ReleaseDetailScaffold
from TrackDetail (invariant trio). PersistentComponentState bridge on all new pages.
Click-to-seek seam designed on MixWaveformVisualizer (inert until wired).
2026-06-12 23:05:25 -04:00
daniel 5f7eaed112 docs: Phase 9 Wave 2 landed — move 9.2 from PLAN to COMPLETED 2026-06-12 22:26:28 -04:00
daniel 46749c8fa4 Merge branch 'p9-w2-api' into dev 2026-06-12 22:18:34 -04:00
daniel ca44fc8794 Phase 9 Wave 2: api/release endpoint family — medium-aware reads + metadata writes
Adds ReleaseRepository/ReleaseManager (IReleaseService) for paged medium-filtered
release reads and Session/Mix satellite writes, UnifiedReleaseService orchestrating
vault+SQL, and ReleaseController (5 endpoints). Refactors WaveformProfileService for
configurable bucketCount/vaultName (backward-compatible) and adds the mix-waveforms vault.
Promotes brittle error-string literals to named constants (MixHasNoTrackMessage,
MixTrackNoAudioMessage) on UnifiedReleaseService.
2026-06-12 22:13:31 -04:00
daniel 22f4939b24 docs: move Phase 9 §9.1 from PLAN to COMPLETED 2026-06-12 21:53:45 -04:00
daniel 93dcc59814 Merge branch 'p9-w1-data-model' into dev 2026-06-12 21:48:56 -04:00
daniel 5d6b54d2fc Phase 9 Wave 1: add ReleaseMedium discriminator + Session/Mix metadata
Add ReleaseMedium enum (Cut/Session/Mix) and two 1:1 satellite entities
(SessionMetadata, MixMetadata) with EF configs and an additive migration.
ReleaseDto.ReleaseType is now nullable, nulled for non-Cut at the converter.
Existing releases default to Cut via column default; no data migration.
2026-06-12 21:47:04 -04:00
daniel 6f63fe7d7c docs: amend Phase 9 spec — apply SOLID review fixes F0-F13 2026-06-12 21:15:36 -04:00
daniel 8087fd04ce docs: SOLID review of Phase 9 spec — waveform compute tier flagged critical 2026-06-12 21:00:04 -04:00
106 changed files with 8188 additions and 412 deletions
+2
View File
@@ -7,3 +7,5 @@
*.yaml text eol=lf *.yaml text eol=lf
*.service text eol=lf *.service text eol=lf
*.conf text eol=lf *.conf text eol=lf
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
+1 -1
View File
@@ -13,7 +13,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. - **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer.
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces. - **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests. - **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey). - **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles, release-track join operations. Release endpoints: paged list with medium filter, single read, mix waveform compute, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests. - **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this. - **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation. - **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
+360
View File
@@ -6,6 +6,366 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
--- ---
## Phase 9 — Release Medium Types
### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality
**Landed:** 2026-06-13 on dev.
The single-track-per-release rule for Session/Mix is enforced only in the CMS form layer (the `BatchUpload`/`BatchEdit` master-list collapse, §9.6.B). This wave makes per-medium cardinality a real domain invariant at the upload-service boundary. Full design — the generalised rule, the enforcement-layer trade-offs, the orphan-avoidance reordering, the relationship to the existing rules, and the back-compat reality — lives in `product-notes/phase-9-medium-cardinality-invariant.md`.
- **What:** Promote per-medium track-count from a form convention to a domain invariant enforced at the upload-service boundary. Declare each medium's allowed cardinality as data — `Cut → 1..N`, `Session → 1..1`, `Mix → 1..1` — in a single `ReleaseMedium`-keyed lookup (`MediumRules`, in `DeepDrftModels`), extensible by one entry per future medium. `UnifiedTrackService.UploadAsync` reads the resolved release's medium + live track count and **rejects** a track-add that would exceed the medium's `Max` (only the find path — a freshly created release is always within range). The existing `CountLiveTracksByRelease` (already on `ITrackService`, backs the delete cascade) supplies the count; no new counting primitive.
- **Why:** Daniel ruled single-track-per-Session/Mix a *hard constraint* (§9.5/§9.6, resolved). Today it is form-deep only — the upload endpoint and any scripted ApiKey caller bypass it, and the first-upload-authoritative write path adds a second track to an existing non-Cut release with no check. The data model itself does not forbid what the product forbids. Hardening it at the service layer makes every domain writer pass the rule, closes the gap, and — by declaring cardinality as one shared rule both the form and the service read — guarantees the UI and the domain cannot drift.
- **Shape:**
- **The rule as data.** `MediumRules.CardinalityOf(medium)` returns a `(Min, Max)` value type; no three-arm `switch` in any service. The same lookup the upload service enforces is the one the CMS form collapse reads (refactor `OnMediumChanged` from its hardcoded `medium is Session or Mix` to `MediumRules.CardinalityOf(medium).IsSingleTrack`) — one source, two consumers (form shapes the UI, service enforces the limit), so they cannot diverge. This is a consume-the-new-rule refactor of §9.6.B's landed collapse, **not** a re-litigation of it.
- **Enforcement in the orchestrator, not `TrackManager`.** The check lives in `UnifiedTrackService` (the true boundary for a track-add-to-a-release operation), not the lower-level SQL `Create`. Express the guard generally — `if (liveCount + 1) > cardinality.Max` — so a future bounded-but-not-single medium is covered by the same line.
- **Reorder to avoid orphaning the vault write.** Today `UploadAsync` writes the vault *before* resolving the release. A rejection at that point orphans the audio. Move the cardinality pre-check **before** `AddTrackAsync`: peek the release by `(album, artist)` (a read via the existing `GetReleaseByTitleAndArtistAsync`, not a create), read its medium + count, reject early — then vault-write only the accepted upload. This reordering is part of the wave, not an afterthought.
- **Violation behaviour.** Return a NetBlocks `ResultContainer` failure with a clear message ("A {medium} release holds a single track; '{title}' already has one"). The controller surfaces it as a `409 Conflict` (honest — well-formed request, rule violation) if cheap, `400` otherwise. The CMS already bubbles upload-failure messages inline; no bespoke UI — the common case never reaches the API because the form collapse stops it first, so this is the backstop for the paths the form does not cover.
- **Leave `ReleaseType`-applicability alone.** Do **not** merge the cardinality rule with the `ReleaseType`-only-for-Cut invariant — they are different kinds of rule (count constraint vs. field relevance). They may co-locate as separate named members of `MediumRules`, but no generic "medium invariant engine." Only cardinality is new this wave.
- **Tests.** Extend `MediumWritePathTests` (the §9.5 EF in-memory fixture): Session/Mix reject a second track-add; Cut accepts the Nth; first track on a new Session/Mix succeeds; `MediumRules.CardinalityOf` returns the declared ranges.
- **Acceptance criteria:** A second track-add to an existing Session or Mix release is rejected at `POST api/track/upload` with a clear failure message and no vault orphan; a Cut release accepts many tracks unchanged; the first track on any medium succeeds; the CMS form collapse and the service enforcement both read `MediumRules` (no duplicated cardinality logic); the existing `ReleaseType`-only-for-Cut enforcement is untouched.
- **Back-compat (verified):** No violating data exists — Phase 9 is unmerged, every release migrated to `Cut` (many-track), zero multi-track Session/Mix releases exist. A DB backstop (if chosen, see open question) goes on clean with no data-cleanup migration; the service check has nothing to reconcile. Note honestly: **no** DB-level cardinality or medium constraint exists today (`ReleaseConfiguration` carries only the `(title, artist)` unique index and the `is_deleted` index) — closing that absence is the wave.
- **Open question (Daniel — philosophy call, not pre-empted):** Enforce the cardinality invariant in the **`UnifiedTrackService` domain layer only** (recommended), or *also* add a **Postgres constraint-trigger DB backstop** so a future writer that bypasses the service cannot violate it?
- **Service-only (recommended).** Consistent with the phase's own documented stance — the `ReleaseType`-only-for-Cut invariant chose service enforcement over `HasCheckConstraint` *by choice, not necessity* (`phase-9-release-medium-types.md` §1); cardinality is the same advisory-vs-storage shape and choosing the DB here would split the phase's philosophy. `UnifiedTrackService` is the *only* track-add path today — the "non-CMS caller" still goes through it (`POST api/track/upload`). The bypass a DB backstop defends against (a writer skipping the service entirely) does not exist in the codebase. And the migration is clean either way, so the backstop is free to add *later* if a second writer ever appears.
- **DB backstop (defer).** A partial unique index cannot express this directly (the medium lives on the `release` table, not `track`; Postgres partial predicates can't cross tables). The expressible form is a hand-written PL/pgSQL constraint-trigger EF does not model — a standing maintenance surface. Defensible only if Daniel wants storage-layer immutability over service-layer truth.
- **Recommendation: service-only (C3), defer the DB backstop (C2) as a free-to-add-later option.** This is a decision about where the system's structural truth lives — the service layer vs. the storage layer — not an implementation detail. It is Daniel's to make. Two minor sub-questions ride along (`409` vs `400` status; `MediumRules` in `DeepDrftModels`) — both have clear recommendations and should not block.
**Completion note:** **Decision: C3 — service-layer enforcement only. NO DB backstop, NO migration, NO trigger** was implemented. `MediumRules` (new, in `DeepDrftModels/Enums/`): a `MediumCardinality` record struct (`Allows`, `IsSingleTrack`) + a `CardinalityOf` lookup declaring `Cut = 1..∞`, `Session = 1..1`, `Mix = 1..1` — one declaration, read by both the service and the form. Enforcement in `UnifiedTrackService.UploadAsync`: a general `(trackCount + 1) > cardinality.Max` guard on the find path (existing release), reordered to run as a **read-only peek BEFORE the vault write** so a rejected over-limit upload never orphans audio. The peek uses a new read-only `GetReleaseByTitleAndArtist` on `ITrackService` (returns medium + live count, no create). Violation → NetBlocks failure result, mapped by `TrackController` to **HTTP 409 Conflict** (via a sentinel message marker mirroring the existing `TrackNotFoundMessage`/`NotFound()` pattern). The CMS form collapse predicates (`BatchUpload.OnMediumChanged`, `BatchEdit.OnMediumChanged` + load-path) were refactored to read `MediumRules.CardinalityOf(medium).IsSingleTrack` — form and service now share one source; behaviour unchanged. `ReleaseType`-only-for-Cut enforcement was left untouched. Nine new tests in `MediumWritePathTests`. Accepted residual items (per the C3 stance): a narrow TOCTOU window between peek and create (single-writer stance accepts it), and an integration-test gap on the no-orphan ordering (no vault seam in the EF in-memory fixture). All acceptance criteria met; Wave 7 hardens per-medium cardinality from a UI convention into a real domain invariant.
---
### 9.8 Wave 8 — Remediation (Phase-9-completion gate met: 8.A8.J + 8.L landed; 8.K post-Phase-9, 8.M follow-on pending)
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L).
Daniel tested the landed Phase 9 surface (Waves 17) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A8.J + 8.L) is now fully met; 8.K (Mix Visualizer) is pulled out of Phase-9-completion scope (post-Phase-9 wave, design-complete); 8.M (legacy-form retirement) is a code-surface-reduction follow-on that trails.
**8.A — Release Archive as medium tabs, not cards**
- **What:** Retire the three navigate-away medium cards (`ReleaseArchiveBrowser`); replace with an in-page `MudTabs` strip (`ALL` + one tab per medium) that swaps the grid below in place. Retire the redundant top-level **Releases** toggle item (the `ALL` tab subsumes it).
- **Why:** The card-grid landing required navigation away to reach per-medium grids. Daniel's testing pass identified the correct shape as an in-page tab strip — medium selection without leaving the page.
- **Shape:** `TrackList.razor` renders a `MudTabs` strip when `VM.Mode == BrowseMode.Albums`: the `ALL` panel hosts `CmsAllReleasesGrid` (the 8.B component); per-medium tabs are enum-driven via `Enum.GetValues<ReleaseMedium>()` with a `MediumTabLabels` dictionary for display text and a `MediumGrid(medium)` render-fragment `switch` for content (Cut → `CmsCutBrowser`, Session → `CmsSessionBrowser Embedded="true"`, Mix → `CmsMixBrowser Embedded="true"`, fallback `_ =>`). The `/tracks/archive` deep-link route resolves to the Releases/Albums mode via URL inspection in `OnInitializedAsync`. `ReleaseArchiveBrowser.razor` and its `.razor.css` were deleted. `BrowseMode.Archive` was removed from `CmsTrackBrowserViewModel.cs`. New `CmsCutBrowser.razor` (a Cut-filtered grid) derives from `CmsMediumBrowserBase`, `Medium => ReleaseMedium.Cut`. `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained an `[Parameter] public bool Embedded { get; set; }` on the subclass (not on `CmsMediumBrowserBase`, which is untouched); when `true`, standalone page chrome (container, title, "Back to Release Archive" button) is suppressed and only the grid renders; standalone routes keep the chrome. Their §9.5.E per-row Edit and hero/waveform row actions are preserved in both contexts. `/tracks/sessions`, `/tracks/mixes`, `/tracks/archive` remain reachable by direct URL. No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference). Known internally-consistent characteristic: CUTS/SESSIONS/MIXES tabs use the thin `CmsMediumTable` grid (cover/title/artist/edit) while ALL uses the richer `CmsAllReleasesGrid` (expand-tracks/delete/Type-chip); per-medium grid richness deferred to 8.C.
**Completion note:** `TrackList.razor` replaced its former three-way toggle (Tracks / Releases / Release Archive) with a two-item toggle (Tracks / Releases); the Releases arm hosts a `MudTabs` strip with `ALL` (→ `CmsAllReleasesGrid`) and enum-driven medium tabs rendered via `MediumTabLabels` + `MediumGrid` render-fragment switch. `ReleaseArchiveBrowser.razor` and `ReleaseArchiveBrowser.razor.css` deleted. `BrowseMode.Archive` removed from `CmsTrackBrowserViewModel.cs`. New file `CmsCutBrowser.razor` (Cut-filtered, derives from `CmsMediumBrowserBase`, no standalone page route). `CmsSessionBrowser.razor` and `CmsMixBrowser.razor` each gained `[Parameter] public bool Embedded { get; set; }` on the subclass; base class untouched. `/tracks/archive` deep-link resolves to Albums mode. All gate acceptance criteria met; 8.C and 8.E layer onto this foundation.
---
**8.D — Type column chip reads "Session" / "DJ Mix" for non-Cuts**
- **What:** The cross-medium releases grid's Type column must not show a Cut-only `ReleaseType` chip (Single/EP/Album) for Session/Mix rows. For non-Cut media the chip reads the medium name — **"Session"** or **"DJ Mix"**.
- **Why:** The CMS Release Archive grid and the `ALL`-tab grid show all releases together. When a Session or Mix row renders a Cut-only `ReleaseType` value, the UI contradicts the medium taxonomy — a Session row should read "Session," not "Single/EP/Album."
- **Shape:** The Type cell was rendering `@context.Release.ReleaseType` unconditionally. Per Phase 9 read-model design, `ReleaseDto.ReleaseType` is nullable and nulled for non-Cut media at the mapping point. The cell becomes medium-aware: when `Medium == Cut`, show `ReleaseType`; otherwise show the medium's display name from a lookup (no hardcoded switch — a future medium's label comes free from the enum + lookup entry).
- **Acceptance criteria:** Cut row's Type chip shows Single/EP/Album; Session row shows "Session"; Mix row shows "DJ Mix"; no row shows a Cut-only `ReleaseType` for a non-Cut medium.
**Completion note:** The Type cell in `CmsAlbumBrowser.razor` was refactored to a single ternary: when `Medium == Cut`, renders `ReleaseType?.ToString() ?? "—"` (reusing the existing em-dash empty-cell idiom used by Genre and Release-Date cells); otherwise renders from `private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTypeLabels` with entries `[ReleaseMedium.Session] = "Session"` and `[ReleaseMedium.Mix] = "DJ Mix"`. Dictionary name is **MediumTypeLabels**. A `@using DeepDrftModels.Enums` was added. Future non-Cut media require exactly one new dictionary entry — no markup change. Acceptance criteria met; Type column now correctly shows "Session" / "DJ Mix" for non-Cut rows.
---
**8.B — `ALL` tab: all-releases grid with edit**
- **What:** The left-most `ALL` tab shows the current cross-medium releases grid (every release, all media) with working edit buttons — the surface the retired **Releases** toggle used to show.
- **Why:** The CMS Release Archive needed a unified view of all releases as a foundation for the tab-strip redesign (8.A). The grid already existed in `CmsAlbumBrowser`; 8.B makes it the `ALL` tab's content.
- **Shape:** `CmsAlbumBrowser` displays the cross-medium releases grid with sort, delete (cascade + orphaned-release cleanup), expand-tracks, and per-row edit, all unchanged. The grid self-loads via `ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`, with an optional `[Parameter] public EventCallback OnReleasesChanged` for host cache invalidation (set in `TrackList.razor` for genre-cache sync). A single `ReloadAsync` path serves both initial load and post-delete refresh.
**Completion note:** `CmsAllReleasesGrid.razor` (new, in `DeepDrftManager/Components/Pages/Tracks/`) wraps `CmsAlbumBrowser` as a self-loading component. Component owns its data load (`ICmsTrackService.GetReleasesAsync` in `OnInitializedAsync`), renders `CmsAlbumBrowser` internally, and refreshes after delete via `ReloadAsync()`. `OnReleasesChanged` callback parameter (optional, safe no-op when unset) lets a host invalidate sibling caches on mutation — `TrackList.razor` `BrowseMode.Albums` now renders `CmsAllReleasesGrid` and passes `OnReleasesChanged` so the genre cache still invalidates on release delete. `CmsTrackBrowserViewModel.cs` was trimmed: the now-redundant album load/cache (`Albums`/`AlbumsLoading`) was removed; `Invalidate()` narrowed to genre-only. `CmsAlbumBrowser` unchanged — sort, delete cascade, expand-tracks, per-row edit, Type chip (per 8.D) all preserved. No `@rendermode` override, no constructor growth, no `IServiceProvider`. No new automated tests (DeepDrftTests has no bUnit/no DeepDrftManager reference; the underlying `GetReleasesAsync` data path is covered by existing tests). Files: `CmsAllReleasesGrid.razor` (new), `TrackList.razor`, `CmsAlbumBrowser.razor`, `CmsTrackBrowserViewModel.cs`. Acceptance criteria met; `ALL` tab grid with edit now live as an embeddable component, clearing the foundation for 8.A tab strip.
---
**8.F — Session hero image in the upload form (retire the two-step)**
- **What:** Compose the hero-image field into the Session upload form so a Session is authored in one pass; remove the "set it later from the browser" alert. Hero is **optional but warns if missing** (no hard gate).
- **Why:** Sessions need their signature hero image. Requiring a post-upload trip to the Session browser is a friction point in the authoring flow. Embedding the hero upload in the creation form (mirroring the deferred cover-art `<InputFile>` UX) lets an admin author a complete Session in one submission.
- **Shape:** `SessionFields.razor` renders a deferred hero-image `<InputFile>` (mirroring the cover-art deferred-upload UX), but **only `@if (AllowHeroUpload)`** — a new bool parameter. `AllowHeroUpload` is threaded `BatchUpload → AlbumHeaderFields → MediumFields → SessionFields` (same chain as the `HeroImageFile`/`HeroImageFileChanged` pair). It defaults `false`; only `BatchUpload` passes it `true`. On the edit forms (`BatchEdit`, `TrackEdit`, `TrackNew`) it stays false, so they show a `Severity.Info` guidance alert pointing to the Sessions browser per-row replace — no dead control. On submit, `BatchUpload` creates the release via the existing upload path, then POSTs the held hero file to the existing resource-addressed `POST api/release/{id}/session/hero-image` using `result.Value.ReleaseId`. Hero is optional with a non-blocking warn-then-proceed gate: a first Session submit with no hero shows a `Severity.Warning` message (`_warningMessage`) and primes acknowledgment; a later submit proceeds. The null-`ReleaseId` edge logs + Snackbars instead of dropping the file silently.
- **Acceptance criteria:** Session upload form shows a hero-image `<InputFile>` alongside the cover art; hero upload optional (warning-then-proceed gate); edit forms show guidance alert instead of the hero field; per-row hero upload in `CmsSessionBrowser` unchanged; no sessions uploaded without hero field available.
**Completion note:** `SessionFields.razor` gained `[Parameter] public bool AllowHeroUpload { get; set; }` and wraps hero-image `<InputFile>` in `@if (AllowHeroUpload)`. Hero image input shows only in upload form, suppressed in edit forms with guidance alert (`Severity.Info` routing to Sessions browser) visible instead. `AllowHeroUpload` parameter threaded through `MediumFields.razor → AlbumHeaderFields.razor → BatchUpload.razor` (set `true` only in `BatchUpload`; defaults `false`). `BatchUpload.razor` holds hero file in a field (`private IBrowserFile? _heroImageFile`) assigned by `SessionFields`'s `HeroImageFileChanged` callback, then POSTs held file to `POST api/release/{id}/session/hero-image` after successful release creation using `result.Value.ReleaseId`. Hero optional with non-blocking gate: `Severity.Warning` on first submit without hero, primes boolean; second submit proceeds (warning dismissed). Null `ReleaseId` edge case logs error + Snackbar instead of silently dropping file. Per-row hero upload in `CmsSessionBrowser` untouched (remains the replace/correct path). Files: `SessionFields.razor`, `MediumFields.razor`, `AlbumHeaderFields.razor`, `BatchUpload.razor`. Acceptance criteria met; hero image now composable in upload form with optional-but-warn semantics.
---
**8.G — "Album Name" → "Release Name" label**
- **What:** The `AlbumHeaderFields` form's first-field label reads **"Release Name"**, not "Album Name."
- **Why:** The field now covers Cuts, Sessions, and Mixes — not just albums. "Release Name" is the accurate noun.
- **Shape:** Rename `Label="Album Name"``Label="Release Name"` and the `RequiredError` string in `AlbumHeaderFields.razor`. Check placeholder/help text for consistency.
- **Acceptance criteria:** The first field of the release header form reads "Release Name"; the required-validation message references "Release Name."
**Completion note:** `AlbumHeaderFields.razor` `Label` and `RequiredError` changed "Album Name" → "Release Name". Matching validation message strings in `BatchEdit.razor` and `BatchUpload.razor` were updated to "Release Name is required" for consistency. Three files total; trivial rename, acceptance criteria met immediately.
---
**8.J — ARCHIVE popover click does not close (bug)**
- **What:** Clicking a popover child leaves the pure-CSS hover dropdown stuck open on SPA navigation. The desktop ARCHIVE menu (a hover-triggered `.dd-nav-dropdown`) has no JS dismissal — it hides only when cursor leaves or focus moves out. After enhanced SPA nav (Blazor keeps the DOM), the cursor often remains over the parent, so the dropdown stays visible.
- **Why:** Dead affordance. An admin clicks "Sessions" in the dropdown, the nav updates in-place, and the dropdown stays floating over the new content, blocking clicks. Dismissal must be explicit (JS-based, not CSS-only).
- **Shape:** Detect SPA navigation and trigger a dismissal handler. The existing `DeepDrftMenu.razor` / `DeepDrftMenu.razor.css` structure carries `.dd-nav-dropdown` with `:hover` and `:focus-within` CSS triggers. A JS `DismissDropdown()` function or a Blazor `@onmouseleave` handler on the parent can close the dropdown imperatively after nav. Coordinate with 8.I: if 8.I flattens the nav and removes the popover entirely on desktop (the three media become inline appbar items), the dismissal logic only survives on breakpoints/sub-menus where a popover remains. Fix applies where the popover still exists.
- **Acceptance criteria:** Clicking a popover child (e.g. "Sessions") closes the dropdown; no dropdown floats after SPA navigation. Desktop and mobile both dismiss correctly.
**Completion note:** `DeepDrftMenu.razor.css` updated with a new `.dd-nav-item-collapsed` rule (scoped `.dd-nav-item-parent.dd-nav-item-collapsed .dd-nav-dropdown`) using `!important` to override both the `:hover` and `:focus-within` show rules. Razor state: collapse tracked in `private readonly HashSet<string> _collapsedDropdowns = []` keyed by `navPage.Route`; parent `<li>` gets the class via `_collapsedDropdowns.Contains(navPage.Route)`. Child link's `@onclick` calls `CollapseDropdown(navPage.Route)` (adds route to set); parent `<li>`'s `@onmouseleave` AND `@onfocusout` both call `ResetDropdown(navPage.Route)` (removes it). Per-parent keying enables multiple independent dropdowns; `@onfocusout` reset lets keyboard users re-enable dropdown without mouse pass. Mirrors existing `CloseMobileMenu` pattern. The dropdown no longer floats after SPA navigation; acceptance criteria met.
---
**8.L — Consolidate release name + track name for single-track releases**
- **What:** For single-track media (Session and Mix), the UI presents **a single name** (Release Name). The track name is **derived from it automatically** on save and kept synced — the admin never enters or sees a separate "Track Name" field. Cuts (multi-track) remain unaffected (separate release and per-track names). This is a consolidation: today these forms surface *two* name inputs for media with only one logical name.
- **Why:** A Session or Mix is a single work with one name. Surfacing a separate "Track Name" invites divergence (release "Lowcountry Live #3" whose track is "untitled-master-final") and a confusing authoring experience. The name consolidation removes that redundancy.
- **Shape:** On **create** (`BatchUpload`, single-track medium): the form presents one name field (Release Name, via 8.G rename); no separate Track Name input. On save, `_tracks[0].TrackName` is set equal to the Release Name. On **edit** (`BatchEdit`, single-track medium): the form presents one name field (Release Name); the per-row Track Name editor is suppressed (via a flag passed to `BatchTrackDetail`). On save, the track's `TrackName` is set equal to the (possibly edited) Release Name — they stay synced. Switching the medium selector mid-form re-drives which name fields are visible (one name for Session/Mix; release + per-track names for Cut) without losing entered data.
- **Acceptance criteria:** Single-track (Session/Mix) **create** path shows one name field (Release Name) with no separate Track Name input; on save, `TrackName == ReleaseName`. Single-track **edit** path shows one name field (Release Name); switching to Cut shows both; the form does not lose entered data on selector change. Track name stays synced with release name on edit (changing Release Name updates the track name). Cuts (multi-track) unaffected — Release Name and per-track Track Names are distinct. Legacy `TrackNew`/`TrackEdit` forms are **out of 8.L scope** (their retirement is 8.M). No public-site changes needed (public detail/gallery views already key off release title only).
**Completion note:** `BatchTrackDetail.razor` gained `[Parameter] public bool ShowTrackName { get; set; } = true;` and wraps Track Name `<MudTextField>` in `@if (ShowTrackName)`. `BatchUpload.razor` removes Track Name input on single-track branch, sets `_tracks[0].TrackName = _albumName` in `SubmitAsync` (after non-empty `_albumName` validation, before upload loop). `BatchEdit.razor` passes `ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"` to `BatchTrackDetail` and syncs `_tracks[0].TrackName = _albumName` in `SaveAsync`. The "is single-track" decision is driven by shared `MediumRules.CardinalityOf(_medium).IsSingleTrack` declaration (same one used by upload service and §9.7 invariant) — not a hardcoded Session/Mix check. Default `true` keeps Cut path and BatchUpload's Cut branch (passing no `ShowTrackName`) showing the field. No `MudForm`/`EditForm` wrapper exists, so hiding the field has no validation-deadlock effect. Single-track forms now present one name, consolidating two-field redundancy; Cuts unaffected with Release Name and per-track names distinct. Acceptance criteria met; Wave 8 track 8.L consolidates form UX to match single-track-per-medium design intent.
---
**8.C — Per-medium grids gain working edit affordances (full parity with ALL tab)**
- **What:** Cut / Session / Mix tab grids gain full parity with the ALL tab: expand-tracks, delete, Type chip, and per-row Edit action — the same rich `CmsAlbumBrowser` grid the ALL tab uses, filtered to each tab's single medium.
- **Why:** The initial 8.A landing acknowledged that the per-medium tabs used the thin `CmsMediumTable` (cover/title/artist/edit) while ALL used the richer `CmsAlbumBrowser`; 8.C was the deferred parity track. Per-medium grids differing from the ALL grid in affordances was confusing and inconsistent.
- **Shape:** Daniel decided option (b) — full parity. Each per-medium browser (`CmsCutBrowser`, `CmsSessionBrowser`, `CmsMixBrowser`) now renders `CmsAlbumBrowser` filtered to its single medium. `CmsAlbumBrowser.razor` gained one optional `[Parameter] public RenderFragment<ReleaseDto>? RowActions` slot, rendered in the Actions cell before the shared edit/delete buttons; the ALL tab leaves it unset and is unchanged. `CmsMediumBrowserBase.cs` was refactored: it now feeds the rich grid a medium-filtered `Releases` projection (`IReadOnlyList<ReleaseDto>`) alongside `ReloadAsync` (wired to the grid's post-delete `OnReleasesChanged`) and a `RowFor(release)` lookup (`_rowsById` dictionary keyed by `release.Id`) for per-medium action-state recovery by the `RowActions` fragment. Session hero and Mix waveform row actions are preserved via each browser's `RowActions` content. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` were deleted (now orphaned). No `TrackList.razor` change (the `MediumGrid` switch renders the same component identifiers). No `@rendermode` override; no constructor growth; no `IServiceProvider`. No new automated tests (no bUnit harness; medium-filter data path covered by `ReleaseBrowseQueryTests`). Files modified: `CmsAlbumBrowser.razor`, `CmsMediumBrowserBase.cs`, `CmsCutBrowser.razor`, `CmsSessionBrowser.razor`, `CmsMixBrowser.razor`, `CmsSessionBrowser.razor.css`; deleted: `CmsMediumTable.razor`, `CmsMediumTable.razor.css`.
**Completion note:** `CmsAlbumBrowser.razor` gained `[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }` rendered in the Actions cell (before edit/delete) via `@RowActions?.Invoke(context.Release)`; the ALL tab's `CmsAllReleasesGrid` wrapper passes nothing, leaving ALL unchanged. `CmsMediumBrowserBase<TRow>` (generic, abstract) was rewritten: it now loads a medium-filtered release list via `ICmsReleaseService.GetPagedAsync`, projects to a bare `IReadOnlyList<ReleaseDto> Releases` for the rich grid, maintains `_rowsById` for action-state recovery via `RowFor(release)`, and exposes `ReloadAsync()` wired to the grid's `OnReleasesChanged`. `CmsCutBrowser`, `CmsSessionBrowser`, and `CmsMixBrowser` were updated to render `CmsAlbumBrowser` (instead of the now-deleted `CmsMediumTable`) with their medium-specific `RowActions` fragment. `CmsMediumTable.razor` and `CmsMediumTable.razor.css` deleted. Per-medium tabs now render the same expand-tracks / delete / Type-chip / edit grid as the ALL tab, single-sourced. Acceptance criteria met; Wave 8 track 8.C brings per-medium grids to full parity with the ALL tab.
---
**8.E — Add-Track buttons in all modes, medium-aware routing**
- **What:** Every Release Archive tab surfaces an Add Track button that routes to the upload page pre-set to that tab's medium. The ALL-tab Add Track defaults to Cut; the medium selector stays user-changeable after landing on the form.
- **Why:** Before 8.E, the upload form had no direct link from the Release Archive tabs. An admin starting from the Sessions tab had no in-context Add Track button pointing at a Session upload.
- **Shape:** `TrackList.razor` gained a `MudStack` Add Track button above `MudTabs` in the `Albums` browse arm (§8.A's tab strip), `@bind-ActivePanelIndex="_activeTabIndex"`, and two helpers: `ActiveMedium` maps tab index 0 (ALL) → `ReleaseMedium.Cut` and index ≥1 → `Enum.GetValues<ReleaseMedium>()[index-1]`; `AddTrackHref(medium)``/tracks/upload?medium={medium.ToString().ToLowerInvariant()}`. `BatchUpload.razor` reads `?medium=` via `[SupplyParameterFromQuery(Name = "medium")]`, parses with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined`, defaults to `ReleaseMedium.Cut`, and routes through the existing `OnMediumChanged` so the pre-selected medium drives the conditional fields on load (the 8.F hero field for Session, `ReleaseType` for Cut) and the 8.L single-track name-collapse runs identically to a user change. The selector stays user-changeable after landing; `/tracks/upload` with no param still defaults to Cut. No `@rendermode` override; no constructor growth; no `IServiceProvider`. `TrackList.razor` edits confined to the tab-strip toolbar (no grid-component / `MediumGrid` switch edits). Files modified: `TrackList.razor`, `BatchUpload.razor`.
**Completion note:** `TrackList.razor` gained `_activeTabIndex` backing field with `@bind-ActivePanelIndex`, `ActiveMedium` computed property (index 0 → `Cut`; index ≥1 → `Enum.GetValues<ReleaseMedium>()[index-1]`), `AddTrackHref(medium)` static helper producing `/tracks/upload?medium={…}`, and a `MudStack` row above `MudTabs` rendering the medium-aware Add Track button. `BatchUpload.razor` gained `[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }` and seed logic in `OnInitializedAsync`: if `MediumParam` is set, `Enum.TryParse<ReleaseMedium>(ignoreCase: true)` + `Enum.IsDefined` gate the call to `OnMediumChanged(medium)`, driving conditional fields and 8.L name-collapse on load without requiring a user gesture. The query-param convention is new to the codebase as a Blazor `[SupplyParameterFromQuery]` entry, mirroring the existing API-side `Enum.TryParse`/`IsDefined` parse posture. Acceptance criteria met; Wave 8 track 8.E surfaces a medium-aware Add Track button in every Release Archive tab.
---
**8.H — Archive page becomes the searchable all-releases browser (release-cardinal, decided H2)**
- **What:** Replace the public `/archive` three-card overview with a release-cardinal searchable browser over all releases. Retire the three-card overview on every breakpoint; cascade: `/tracks` (`TracksView`) is demoted from the nav (route kept reachable); mobile ARCHIVE → the new browser.
- **Why:** The three-card overview is dead weight — it merely summarizes what the site offers without letting the user interact with actual content. The real archive experience is discovering and exploring releases across all media with search, filtering, and per-medium detail pages. A searchable all-releases browser is what "archive" means to a listener.
- **Shape:** New `ArchiveView` (`.razor` + `.razor.cs` + `.razor.css`): debounced Title/Artist search, an enum-driven medium filter (`All` + per-medium from `Enum.GetValues<ReleaseMedium>()` + a label lookup, so a fourth medium surfaces from one entry), and a genre filter sourced from the existing distinct-genres list. Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}` (the established `AlbumsView` Cut destination, since Cuts have no single-release detail page). The unfiltered first page is bridged across the prerender→WASM seam via `PersistentComponentState` (keyed `"archive-releases"`, persisted/restored only when no filter is active), matching the `TracksView`/`AlbumsView` pattern. No page-level `@rendermode` override. **API surface grew (additive, backward-compatible):** new `ReleaseFilter` DTO (`SearchText`, `Genre`, `IsEmpty`) mirroring `TrackFilter`; `q` + `genre` query params threaded through `ReleaseController``ReleaseProxyController``ReleaseClient`/`IReleaseDataService`/`ReleaseClientDataService` and `ReleaseManager`/`IReleaseService`/`ReleaseRepository.GetPagedByMediumAsync`. Search uses parameterized `EF.Functions.ILike` over Title/Artist (Npgsql); genre is exact-match. No constructor growth, no `IServiceProvider` — optional params on existing signatures. New test `ReleaseBrowseQueryTests` covers the repository query path (medium/genre/compose/null-passthrough/soft-delete; the `ILike` search is a Postgres-DSN-gated integration test that skips without a DB).
- **Acceptance criteria:** `/archive` is a searchable, filterable all-releases browser with debounced search, medium and genre filters; cards navigate to correct per-medium detail routes; unfiltered first load is prerendered and bridged via persistent state; existing `/tracks` route stays reachable but is removed from public nav; no three-card overview remains.
**Completion note:** `ArchiveView` (at `/archive`) rewritten in place from the three-card overview to a release-cardinal searchable browser. New `ArchiveView.razor`, `ArchiveView.razor.cs`, `ArchiveView.razor.css` implemented with debounced search (Title/Artist), medium filter (enum-driven, no hardcoded switch), and genre filter (sourced from distinct-genres list). Cards route per-medium: Session → `/sessions/{id}`, Mix → `/mixes/{id}`, Cut → `/tracks?album={title}`. Unfiltered first load persisted/restored via `PersistentComponentState` keyed `"archive-releases"` (following `TracksView`/`AlbumsView` pattern). New `ReleaseFilter` DTO added with `SearchText`, `Genre` (string, optional), `IsEmpty` (bool). `ReleaseController` extended with `q` and `genre` optional query params on `GetPagedByMedium` endpoint; params threaded to `ReleaseProxyController` and down through data-service layers. Repository method refactored: `GetPagedByMediumAsync` now accepts optional `searchText` and `genre` parameters, applies parameterized `EF.Functions.ILike` for search over Title/Artist (Npgsql), exact-match for genre. New integration test `ReleaseBrowseQueryTests` covers medium filter, genre filter, compose, null passthrough, soft-delete; `ILike` search integration-only, skips without Postgres DSN. Old `/archive` three-card overview removed. API surface backward-compatible (all new query params optional, existing `medium` filter unchanged). Navigation structure unchanged; `/tracks` (`TracksView`) remains in nav and routable (demotion from nav, and removal of GENRES, are explicit work items for track 8.I). Three-card overview fully retired; public archive is now the searchable all-releases browser; acceptance criteria met.
---
**8.I — Nav slimmed: ARCHIVE + three medium modes inline, GENRES removed**
- **What:** Above the medium breakpoint the appbar carries ARCHIVE (the new release-cardinal browser) and the three medium modes (CUTS/SESSIONS/MIXES) as direct inline links. GENRES removed from the nav. `/tracks` (`TracksView`) demoted from the nav (route kept reachable).
- **Why:** The nav was cluttered with redundant levels (ARCHIVE popover + separate Tracks/Genres entries). Flattening the medium links into the appbar alongside ARCHIVE streamlines navigation; removing GENRES (while keeping the route) reduces clutter. The real archive is release-cardinal (8.H); the `/tracks` track-cardinal gallery is no longer the primary public browse surface.
- **Shape:** `Pages.cs` `MenuPages` removes GENRES and `Tracks` entries; keeps ARCHIVE (now linking to the searchable all-releases browser per 8.H) with no children in the menu model (the three media become inline appbar siblings). `DeepDrftMenu.razor` flattens ARCHIVE and the three medium items into inline `<a class="dd-nav-link">` siblings above the `sm` (600px) breakpoint; the mobile renderer keeps ARCHIVE with the three media indented in the hamburger drawer. The desktop hover popover (`.dd-nav-dropdown`, `:hover`/`:focus-within` CSS, dead-code collapse/reset machinery from 8.J) is removed as now-dead code — no desktop popover renders at any width ≥600px, and the only surviving popover surface (mobile drawer) already dismisses on child click via `CloseMobileMenu`. Code review verified: no desktop popover regression at any breakpoint, mobile drawer dismiss unchanged.
**Completion note:** `Pages.cs` `MenuPages` trimmed: **Tracks** and **Genres** entries removed; ARCHIVE retains its three medium children (Cuts/Sessions/Mixes) unchanged as the single nav data shape — no duplication or child nulling. `/tracks` and `/genres` routes remain reachable by direct URL. `PageRoute.HasChildren` is now unreferenced but left in place. `DeepDrftMenu.razor` refactored: above `sm` breakpoint the renderer builds a flat `<ul>` of ARCHIVE + Cuts/Sessions/Mixes as inline `<a>` nav links (no popover nesting); below `sm` breakpoint the mobile `<ul>` keeps ARCHIVE as a parent with indented media children (existing drawer pattern, unchanged). `DeepDrftMenu.razor.css` removes `.dd-nav-dropdown` (hover popover display), `.dd-nav-item-parent` (parent hover state), and `.dd-nav-item-collapsed` (popover collapse toggle from 8.J). Remaining CSS is the link and mobile-drawer base styles. The collapse/reset JavaScript state and methods (`_collapsedDropdowns`, `CollapseDropdown`, `ResetDropdown` from 8.J) are removed as unreferenced once the popover disappears. Files: `Pages.cs`, `DeepDrftMenu.razor`, `DeepDrftMenu.razor.css`. All acceptance criteria met: ARCHIVE and three media are inline appbar links at desktop breakpoint; GENRES removed from nav while `/genres` route remains reachable; `/tracks` demoted from nav while route remains reachable; mobile drawer keeps ARCHIVE + media sub-list; no popover floats at any breakpoint; no nav regression.
---
### 9.6 Wave 6 — Gap Closure
**Landed:** 2026-06-13 on dev.
Two functional gaps the landed Phase 9 surface left open. Both are real (medium intent not honoured at a surface that should honour it), neither is debt. **A is a product decision** (which destination the home-page cards take) and is gated on Daniel — its build is one line of markup either way, but the *shape* of the answer is his to pick. **B is clear-cut** (mirror an existing collapse already proven on the upload path into the edit path). A and B are independent; B can land immediately, A waits on the open question below.
**9.6.A — Home-page editorial cards have no medium destinations**
- **What:** The three "Music through Every Medium" editorial cards on `Home.razor` (Studio / Live / DJ Mix — landed §8.6) still render as non-navigating `<div>`s. They carry a deferral comment — `@* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@` — written before the medium browse routes existed. Today `/cuts`, `/sessions`, `/mixes` are live and working (§9.4); the only thing that points anywhere from this section is the section CTA "Explore the Archive" → `/tracks`. The cards are the most prominent medium framing on the public site and they are dead ends.
- **Why it matters:** This section *is* the home page's pitch of the three-medium taxonomy. Leaving the cards inert undercuts the whole Phase 9 narrative — a visitor reads "Studio / Live / DJ Mix," clicks the most prominent thing on the page, and nothing happens. The destinations now exist; the only question is which destination is right. The TODO's `/tracks?format=` premise is very likely **obsolete** — it predates the medium browsers, which already give each card a real home.
- **Shape:** Depends on the open question. Either is small:
- **(a) Link the three cards to the existing medium browsers** — Studio → `/cuts`, Live → `/sessions`, Mix → `/mixes`. Promote each `.medium-card` `<div>` to an `<a href>` (the §8.6 spec already anticipated this: "promoting to `<a>` later is a one-line change" — the hover styles assume the affordance). Zero new surface; the routes exist today. Removes the stale TODO.
- **(b) Build a `/tracks?format=<medium>` filtered gallery first, then point the cards there** — a flat cross-medium gallery pre-filtered by medium (grid/list toggle, the `TracksView` ergonomics), distinct from the medium-specific browsers. Honours the original TODO's literal premise but adds a surface that does not exist yet: a `format`/`medium` query param on `TracksView` + its VM, plus the routing. The cards then deep-link into that one gallery, pre-filtered.
- **Acceptance criteria:** Each of the three editorial cards navigates to a live medium destination on click (desktop and mobile); the stale `/tracks?format=` TODO is resolved (removed under (a), or satisfied under (b)); no card remains a dead `<div>`.
- **Open question (Daniel — product decision, do not pre-empt):** Should the cards point at the **existing medium browsers** (`/cuts` / `/sessions` / `/mixes`, shape (a)) or at a **new `/tracks?format=` filtered gallery** (shape (b))?
- **(a)** is trivial and honest about what the site already offers — the medium browsers are the canonical per-medium surfaces, and a card that says "Studio Releases" landing on `/cuts` is exactly truthful. The TODO that asked for `/tracks?format=` was written before those browsers existed and is plausibly just stale.
- **(b)** adds a surface but unifies the browse experience under one flat gallery the visitor can re-filter in place — the card is an entry point into a single explorable gallery rather than three sibling destinations. Worth it only if Daniel wants the flat cross-medium gallery to be the primary public browse model rather than the medium-specific browsers.
- **Note:** (a) requires **no new code beyond the three `href`s** (and the `<div>``<a>` promotion the §8.6 spec pre-authorised); the `/cuts`, `/sessions`, `/mixes` routes already satisfy it. (b) is a genuine new view. The choice is Daniel's — it is a question of which browse model the home page should funnel into, not an implementation detail.
**9.6.B — `BatchEdit` single-track form-shape collapse not applied on the edit path**
- **What:** `BatchUpload.razor` enforces the single-track invariant (§9.3 resolved: Session/Mix are one-track-per-release) by collapsing its multi-track master list to a single row when the medium is Session or Mix — `OnMediumChanged` trims the form to row 1. The edit path `BatchEdit.razor` (`/tracks/album/{AlbumName}/edit`) was not given the same collapse; a code comment flags the deferral. Opening a Session or Mix release in `BatchEdit` today shows the **full multi-track master list** — a form shape that, by the phase's own resolved invariant, should not exist for those media.
- **Why it matters:** The edit form contradicts the data model it edits. Sessions and Mixes are single-track by design and the upload path already enforces that; the edit path showing a multi-track list invites an admin to add tracks to a release that is not supposed to have them, and presents an inconsistent authoring experience between create and edit for the same medium. It is the upload-path invariant left half-applied.
- **Shape:** Mirror `BatchUpload`'s `OnMediumChanged` collapse logic into `BatchEdit`. When the loaded (or selected) medium is Session or Mix, collapse the master list to a single track row and hide the add-track affordance, exactly as `BatchUpload` does — `BatchUpload.OnMediumChanged` is the reference implementation; reuse its shape rather than authoring a second one (the collapse logic is a candidate to lift into a shared helper or the `MediumFields` dispatch if it reads cleanly, but parity with upload is the requirement, shared extraction is the nicety). The medium selector wiring into `BatchEdit`'s submit path already landed in §9.5.B; this is the form-shape half that did not.
- **Acceptance criteria:** Opening a Session or Mix release in `BatchEdit` shows a single-track form with no add-track affordance, matching `BatchUpload` for the same medium; opening a Cut release is unchanged (full multi-track list); switching the medium selector to Session/Mix within `BatchEdit` collapses the list live, the same gesture `BatchUpload` performs.
- **Open question:** How should `BatchEdit` render an **existing Session/Mix release that already holds multiple tracks** (e.g. one created before the §9.3 single-track invariant landed, or mis-authored)? Collapsing the form to row 1 would visually hide tracks 2..n without deleting them — the admin sees one track, the DB holds several, and a save could silently orphan the editing of the hidden tracks. Recommend the safe reading: if a non-Cut release loads with >1 live track, do **not** silently collapse — show the full list with an inline warning ("Sessions and Mixes are single-track; this release has N — remove extras to conform") and let the admin reconcile, only enforcing the single-row collapse once the release is already conformant. This keeps the invariant from destroying data it was added after. Flag for Daniel; the collapse-on-conformant-release behaviour (the common case) is unambiguous and can land regardless.
**Dependency summary for Wave 6:** A and B are independent. B is unblocked and clear-cut (mirror the proven `BatchUpload` collapse). A is blocked only on the Daniel product decision above — once the destination is chosen, its build is trivial. Neither depends on the other.
**Completion note:** 9.6.A — Home-page editorial cards on `Home.razor` linked to medium-specific browsers (decision (a) implemented): Studio → `/cuts`, Live → `/sessions`, Mix → `/mixes`. Each `.medium-card` `<div>` promoted to `<a href>` navigating to the corresponding route; stale `/tracks?format=` TODO removed. 9.6.B — `BatchEdit.razor` single-track form-shape collapse mirrored from `BatchUpload.OnMediumChanged`: when loaded or selected medium is Session or Mix, master list collapsed to single row with no add-track affordance, matching upload-path invariant. The open question about existing multi-track Session/Mix releases was resolved by Daniel as **straight collapse, no warning path** — Phase 9 is unmerged so zero legacy multi-track data exists; the collapse logic in `OnInitializedAsync` (lines 197200) and `OnMediumChanged` (lines 143151) silently trims to one track on load and on selector change with no defensive UI. Wave 6 closes functional gaps in Phase 9 medium taxonomy surface; no regressions, both items clarify intent where taxonomy did not yet reach.
---
### 9.4 Wave 4 — Public site: ARCHIVE nav, CUTS / SESSIONS / MIXES, waveform visualizer
**Landed:** 2026-06-13 on dev.
- **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 `<a>` list today with no dropdown mechanism. Recommend extending the nav model with an optional `Children` collection (generalizes to future dropdowns) over a bespoke hardcoded popover. Pinned semantics (spec §5.1): dual-role nodes — desktop hover opens children, desktop click navigates to the parent's route (`/archive`), mobile renders the parent as a link with children indented; depth cap of **one level** — deeper nesting is a redesign, not a recursion.
- **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.
- **Resolved:** When `/cuts` lands, the existing `/albums` route issues a redirect to `/cuts`. Old URLs keep working; no hard 404.
- **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 defining visual is a **`MixWaveformVisualizer`** component fed by the preprocessed waveform datum from `MixMetadata`, rendered as the **full-page background** of the detail page. 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 a high-resolution, sophisticated **full-page background** visual in **its own visual language** — explicitly *not* the `SpectrumVisualizer` / `LevelMeterFab` peak-bar idiom, which is **reserved for the player bar**. The two are siblings in subject matter (waveforms) with entirely separate design treatments; they share a data pipeline (9.2.B), never a look. Detail composes the same `ReleaseDetailScaffold`, with the visualizer as the page-background layer.
- **Acceptance criteria:** `/mixes` lists Mix releases; `/mixes/{id}` renders the waveform visualizer as the page background fed by real datum (seedable via the 9.2.B trigger, no CMS required); the visualizer is a standalone reusable component visually distinct from the player-bar idiom.
- **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 for both **build and acceptance** — the body-less 9.2.B waveform trigger seeds real Mix datum and a script can seed hero images, with no CMS in existence.
- **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. Scaffold contract (spec §5.3): it owns exactly the invariant trio — metadata block, play affordance, player wiring; all per-medium variance rides slots (a boolean layout parameter on the scaffold is a design failure). `TrackDetail` is refactored onto the scaffold in this wave (it is the extraction source — nearly free); if deferred, record the fork as deliberate debt with a retirement note.
**Completion note:** ARCHIVE nav item implemented in `DeepDrftMenu.razor` with optional `Children` collection support in the page model for desktop popover/mobile dropdown. `/archive` overview page renders three medium cards (reusing §8.6 card design). `/cuts` route added, parameterizing `AlbumsView` with medium filter; `/albums` redirects to `/cuts`. `/sessions` gallery and `/sessions/{id}` detail pages implemented with hero-image-dominant layout; detail composes shared `ReleaseDetailScaffold`. `/mixes` gallery and `/mixes/{id}` detail pages implemented; detail features `MixWaveformVisualizer` full-page background component rendering waveform from `MixMetadata.WaveformEntryKey`. `ReleaseDetailScaffold` extracted from `TrackDetail` carrying invariant metadata + play + player wiring; `TrackDetail` refactored to use scaffold. `ReleaseClient` HTTP service and `ReleaseClientDataService` implemented alongside `ReleaseProxyController` in `DeepDrftPublic`. Waveform visualizer click-to-seek position binding seam designed (inert, feature shipping later). All acceptance criteria met; Wave 4 completes Phase 9 on the public site.
---
### 9.5 Wave 5 — Gap Cleanup
**Landed:** 2026-06-13 on dev.
Waves 14 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: **AB are blockers** (data correctness); **CE 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<ReleaseMedium>` (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 394395 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 (AE) 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.
**Completion note:** `POST api/track/upload` endpoint extended with `[FromForm] string? medium` parameter, defensively parsed via `Enum.TryParse<ReleaseMedium>` and threaded through `UnifiedTrackService.UploadAsync``ReleaseDto`. Release finds are unchanged — existing releases never get their medium updated on subsequent track adds. `PUT api/track/meta` endpoint and `UpdateTrackMetadataRequest` extended with `ReleaseMedium? Medium` field; controller applies non-null values to `release.Medium` alongside `ReleaseType` conditional logic, resetting `ReleaseType` to `Single` for non-`Cut` media. `CmsTrackService.UpdateAsync` signature updated to accept `ReleaseMedium? medium` parameter and include it in the JSON body. `ICmsTrackService` interface updated. `TrackEdit.razor` and `BatchEdit.razor` now wire the `MediumFields` selector into the edit submit path. `ReleaseDetailViewModel.Load` updated to call `GetPage(releaseId: release.Id)` instead of the fragile album-title string join. `TrackFilter` extended with `long? ReleaseId` field. `GET api/track/page` endpoint now accepts `releaseId` query parameter and threads it through repository/service layers as an additional WHERE predicate. Two releases with identical titles now return their own correct tracks on detail pages. `Pages.MenuPages` updated to add "Tracks" entry to public nav alongside ARCHIVE and Genres (Option A). `/tracks` is now reachable from the public menu. `CmsSessionBrowser` and `CmsMixBrowser` each gain a per-row Edit `MudIconButton` linking to `BatchEdit` with URI-escaped release title. `CmsMediumBrowserBase` abstract base extracted carrying `_loading`, `_rows`, `OnInitializedAsync`, `LoadAsync`, `ThumbUrl` infrastructure. Shared `CmsMediumTable` Razor component implemented with `ActionContent` `RenderFragment` slot for per-medium actions. `CmsSessionBrowser` and `CmsAlbumBrowser` refactored to inherit from base and supply medium-specific action markup. New medium browser requires only action fragment implementation, zero structural duplication. All acceptance criteria met; Wave 5 completes Phase 9 Wave cleanup across API, public site, and CMS.
---
### 9.3 Wave 3 — CMS: Release Archive tab, medium selector, medium browsers
**Landed:** 2026-06-13 on dev.
- **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<ReleaseMedium>()` + 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 ride **per-medium section components** (`CutFields` / `SessionFields` / `MixFields` — plain explicit markup inside, no clever generics) behind a **single dispatch point** (a `MediumFields` component holding the one `@switch`) embedded by all five forms — one dispatch, not five scattered conditional blocks. A new medium is one section component + one dispatch entry.
- **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 `HeroImageEntryKey`.
- **Acceptance criteria:** Session browser lists only Session releases; uploading a hero image persists it and renders the thumbnail.
- **9.3.C — `CmsMixBrowser` + waveform trigger wiring.**
- **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 call the server-side waveform trigger (9.2.B) — the CMS never computes or carries the datum.
- **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. The CMS has no in-process data layer by convention, so all it does is fire the trigger.
- **Shape:** Upload flow: `UploadTrackAsync``POST api/release/{id}/mix/waveform` (body-less; the API computes and stores server-side, 9.2.B). The per-row Generate action is the same trigger — recovery costs one POST, with no download/recompute/re-upload of the catalogue's longest audio files.
- **Acceptance criteria:** Mix browser lists only Mix releases and shows per-row waveform status; uploading a Mix fires the trigger and the stored high-res waveform appears as generated; the per-row Generate action recovers a missing waveform.
- **Prerequisite:** 9.2.
- **Open questions:**
- **Genre browse fate.** Resolved: 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.
- **Waveform preprocessor reuse.** Resolved: one server-side parameterized pipeline (player-bar peek = low-res, Mix = high-res; *One source, multiple views*). The `WaveformProfileService` resolution-parameter refactor lands in **Wave 2 with the trigger endpoint (9.2.B)**, not in this wave.
- **Single-track invariant.** Resolved: hard constraint. One track per Session/Mix release is enforced at upload — the CMS form for those media drops the multi-track master list entirely.
**Completion note:** Genre tab in `TrackList.razor` renamed to Release Archive; medium card group (Cut / Session / Mix) implemented with enum-driven dispatch to medium-specific browsers (no hardcoded switches). `ReleaseArchiveBrowser` component renders three cards navigating to `CmsAlbumBrowser`, `CmsSessionBrowser`, `CmsMixBrowser`. `MediumFields` single-dispatch component added with per-medium field groups (`CutFields`, `SessionFields`, `MixFields`) embedded by `TrackNew`, `TrackEdit`, `BatchUpload`, `BatchEdit`, `AlbumHeaderFields`; `ReleaseType` visible only for Cut medium. `CmsSessionBrowser` implemented as flat list of Session releases with cover + hero thumbnail columns; hero-image upload via `POST api/release/{id}/session/hero-image` integrated into upload/edit path. `CmsMixBrowser` implemented as flat list of Mix releases with in-grid waveform status column (mirroring Phase 8's `HasWaveformProfile` pattern) and per-row Generate trigger via `POST api/release/{id}/mix/waveform`. Single-track invariant enforced in `BatchUpload` for Session/Mix. **Known gap:** medium write path (medium field in `POST api/track/upload` and `PUT api/track/meta` requests) not yet implemented — to be spec'd as Phase 9 Wave 5. All acceptance criteria met; Wave 3 completes Phase 9 on the CMS.
---
### 9.2 Wave 2 — API: medium reads + metadata uploads
**Landed:** 2026-06-12 on dev.
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 + medium metadata). The **list** read `Include`s the matching metadata table via a per-medium projection map; the **by-id** read always-`Include`s both metadata navs (two 1:1 unique-FK joins; non-matching media naturally yield nulls — no per-medium branching, no 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 on list reads; base release reads never touch them. The projection map carries a dual responsibility: per-medium `Include` selection *and* the single enforcement point of the medium↔metadata correlation (a metadata DTO is populated iff the medium matches) — which is why it is not inlined in the controller. The honest extensibility guarantee is "one entry, one file," not "zero controller changes." `ReleaseDto` gains `Medium`, a **nullable** `ReleaseType?` (nulled at the mapping point for non-`Cut`), and 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 and a non-null `ReleaseType`; non-Cut releases serialize `ReleaseType: null`; pagination + sort parity with `api/track/page`.
- **9.2.B — Metadata write endpoints.**
- **What:** `POST api/release/{id}/session/hero-image` (ApiKey, multipart — hero image → image vault → set `SessionMetadata.HeroImageEntryKey`) and `POST api/release/{id}/mix/waveform` (ApiKey, **no request body** — a server-side trigger: the API fetches the mix audio from its own vault, computes the high-resolution waveform via `WaveformProfileService` parameterized by resolution, stores the datum in the vault, sets `MixMetadata.WaveformEntryKey`). Both routes are resource-addressed — the release id rides the route.
- **Why:** The CMS authoring flows (Wave 3 B/C) need write paths for the medium-specific data, and the waveform is a *derived* datum the server can compute from audio it already owns. Mirroring the existing body-less `POST api/track/{trackId}/waveform` idiom makes the datum correct by construction (no trusting a client blob) and keeps the CMS free of any in-process data layer (its standing constraint). Splitting these 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 `HeroImageEntryKey`. The waveform trigger includes the `WaveformProfileService` refactor: a per-call resolution/profile parameter (today fixed via injected `WaveformProfileOptions.BucketCount = 512`) plus a distinct entry-key/vault target for the high-res datum — one pipeline, two resolutions (*One source, multiple views*). Both endpoints find-or-create the metadata row for the release.
- **Acceptance criteria:** Posting a hero image to a Session release sets `HeroImageEntryKey` and the image is served back through the existing image proxy; the body-less waveform trigger on a Mix release computes + stores a high-res datum, sets `WaveformEntryKey`, and the datum is retrievable.
**Completion note:** Five new endpoints on `ReleaseController` implemented and integrated. `ReleaseRepository` + `ReleaseManager` (`IReleaseService`) in `DeepDrftData` provide paged medium-filtered reads and satellite metadata writes. `UnifiedReleaseService` orchestrates vault + SQL operations in `DeepDrftAPI/Services/`. `ReleaseDto` updated with `Medium` field and nested `SessionMetadataDto?` / `MixMetadataDto?` properties. Per-medium projection map enforces medium↔metadata correlation at the single mapping point. `WaveformProfileService` refactored with optional `bucketCount?` and `vaultName?` parameters supporting multiple resolutions. `VaultConstants.MixWaveforms = "mix-waveforms"` added. Five endpoints serve reads (`GET api/release` with medium filtering and pagination, `GET api/release/{id}` with both metadata tables included) and writes (`POST api/release/{id}/session/hero-image`, `POST api/release/{id}/mix/waveform`). All acceptance criteria met; Wave 3 (CMS) now unblocked.
---
### 9.1 Wave 1 — Data model + migration
**Landed:** 2026-06-12 on dev.
- **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` (`HeroImageEntryKey`) 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 *and* the named `CutMetadata`-rejected exception (see the phase intro above).
- `session_metadata` and `mix_metadata` tables, each with a unique FK to `releases` (1:1). `MixMetadata.WaveformEntryKey` is a vault entry key (resolved — 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 — a deliberate choice; EF supports check constraints, see the phase intro).
- **Open questions:**
- **Resolved — waveform storage:** vault blob + `WaveformEntryKey`. Settled by the server-side trigger design (9.2.B): the API computes and stores the datum vault-side; SQL holds only the entry key, so a JSON column never enters the flow. This wave adds only the SQL column — the vault write rides the existing vault abstraction server-side.
**Completion note:** `ReleaseMedium` enum with `Cut`, `Session`, `Mix` values implemented in `DeepDrftModels/Enums/`. `ReleaseEntity` extended with `Medium` column (default `Cut`) and 1:1 nav properties to `SessionMetadata` and `MixMetadata`. New entities added with their EF configurations. Additive migration `AddReleaseMedium` authored and applied. `ReleaseDto` updated with `Medium` field and nested metadata DTOs. `TrackConverter` updated. Solution builds; existing releases read back as `Cut`; acceptance criteria met.
---
## Phase 8 — CMS Track Browser ## Phase 8 — CMS Track Browser
### 8.6 "Music through Every Medium" home page section ### 8.6 "Music through Every Medium" home page section
+140 -11
View File
@@ -6,13 +6,15 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose ## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary) and images (FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. Two image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for track/image endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.** Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles, and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (mix waveform compute, session hero-image upload). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only) ## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding. AuthBlocks startup: `AddAuthBlocks`, `UseAuthBlocksStartupAsync`, `MapAuthBlocks`, authentication/authorization middleware. - `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding. AuthBlocks startup: `AddAuthBlocks`, `UseAuthBlocksStartupAsync`, `MapAuthBlocks`, authentication/authorization middleware.
- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`). - `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`).
- `Controllers/TrackController.cs`: Seven track endpoints (see below). - `Services/UnifiedReleaseService.cs`: Host-internal orchestrator. Coordinates release mutations (mix waveform compute + store, session hero-image upload + link).
- `Controllers/TrackController.cs`: Track endpoints (see below).
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only). - `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code. - `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo). - `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
@@ -26,7 +28,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary) and imag
- EF Core context and repository — in `DeepDrftData`. - EF Core context and repository — in `DeepDrftData`.
- **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration). - **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration).
## The endpoint surface (seven endpoints) ## The endpoint surface
### GET api/track/{trackId} (unauthenticated) ### GET api/track/{trackId} (unauthenticated)
@@ -37,6 +39,69 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- Streams the file directly from disk with `enableRangeProcessing: true`, supporting both full-file and partial-range requests without synthesizing WAV headers or buffering. - Streams the file directly from disk with `enableRangeProcessing: true`, supporting both full-file and partial-range requests without synthesizing WAV headers or buffering.
- Returns 200 for full-file requests, 206 for Range requests, 404 if track not found, 500 if vault operations fail (with error swallowing — the vault returns `null`). - Returns 200 for full-file requests, 206 for Range requests, 404 if track not found, 500 if vault operations fail (with error swallowing — the vault returns `null`).
### GET api/track/albums (unauthenticated)
Returns a list of all releases with per-release track counts. Public browse data, same auth posture as `GET api/track/page`.
- **Response**: `List<ReleaseDto>` where each release carries its title, artist, genre, release date, medium, and track count.
- Returns 200 with the release list on success. Returns 500 on query error.
### GET api/track/genres (unauthenticated)
Returns distinct non-null genres with per-genre track counts. Public browse data, same auth posture as `GET api/track/page`.
- **Response**: A collection of genre strings with track counts.
- Returns 200 on success. Returns 500 on query error.
### GET api/track/random (unauthenticated)
Picks one track at random from the full library and returns its metadata. Public, same auth posture as `GET api/track/page`.
- **Response**: A single `TrackDto` selected uniformly at random.
- Returns 200 on success. Returns 404 if the library is empty (a valid state). Returns 500 on query error.
### GET api/track/{trackId}/waveform (unauthenticated)
Returns the stored waveform loudness profile for a track as base64-encoded bytes. Public listener data, same auth posture as `GET api/track/{trackId}`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- **Response**: `WaveformProfileDto` with `BucketCount` (number of loudness buckets) and `Data` (base64-encoded byte array).
- Returns 200 on success. Returns 404 if no profile is stored (existing tracks may predate profiling, or computation failed at upload — the frontend falls back to a flat seekbar). Returns 500 on vault error.
### POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
Admin backfill: computes and stores a waveform profile for an existing track from its vault audio.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
- Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails.
### GET api/track/meta/by-key/{entryKey} (unauthenticated)
Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable through the public proxy.
- **Route parameter `entryKey`** (string): the TrackEntity.EntryKey.
- **Response**: `TrackDto` for the matching track.
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/track/waveform-status ([ApiKeyAuthorize])
Admin backfill view: returns every track with a flag indicating whether a waveform profile is stored. Used by the CMS PreProcessing panel to flag tracks needing waveform computation.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, and `HasProfile` (bool).
- Returns 200 on success. Returns 500 on query error.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- Calls `ITrackService.DeleteRelease`.
- Returns 200 on success. Returns 500 on deletion error.
### PUT api/track/{trackId} ([ApiKeyAuthorize]) ### PUT api/track/{trackId} ([ApiKeyAuthorize])
**Authenticated endpoint.** Writes pre-processed audio bytes to the `tracks` vault. **Authenticated endpoint.** Writes pre-processed audio bytes to the `tracks` vault.
@@ -61,10 +126,13 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- `genre` (string, optional) - `genre` (string, optional)
- `releaseDate` (string, optional, format `YYYY-MM-DD`) - `releaseDate` (string, optional, format `YYYY-MM-DD`)
- `createdByUserId` (long, required): audit trail — who uploaded this track. - `createdByUserId` (long, required): audit trail — who uploaded this track.
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure. - The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory. - `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). - Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 500 if processing fails. - Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize]) ### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -75,17 +143,20 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey. - Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey.
- Returns 200 on success, 404 if track not found, 500 if deletion fails. - Returns 200 on success, 404 if track not found, 500 if deletion fails.
### GET api/track/page ([ApiKeyAuthorize]) ### GET api/track/page (unauthenticated)
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser. Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Query parameters**: - **Query parameters**:
- `page` (int, optional, default 1): 1-based page number. - `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): tracks per page. - `pageSize` (int, optional, default 20): tracks per page.
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`. - `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
- `sortDescending` (bool, optional, default false): sort direction. - `sortDescending` (bool, optional, default false): sort direction.
- Calls `ITrackService.GetPaged` (via DI), which is actually `TrackManager` from `DeepDrftData`. - `q` (string, optional): search text filter (matches track name / artist).
- `album` (string, optional): album title filter.
- `genre` (string, optional): genre filter.
- `releaseId` (long?, optional): release ID filter (authoritative join; preferred over album title).
- Calls `ITrackService.GetPaged` with optional `TrackFilter` (null if all filter params are empty).
- Returns 200 with `PagedResult<TrackDto>` JSON (`Items`, `TotalCount`, `PageNumber`, `PageSize`). Returns 500 on query error. - Returns 200 with `PagedResult<TrackDto>` JSON (`Items`, `TotalCount`, `PageNumber`, `PageSize`). Returns 500 on query error.
### GET api/track/meta/{id:long} ([ApiKeyAuthorize]) ### GET api/track/meta/{id:long} ([ApiKeyAuthorize])
@@ -103,9 +174,18 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`. - **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID. - **Route parameter `id`** (long): the SQL track ID.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` (tri-state: null = no change, "" = clear, value = set). - **Body**: `UpdateTrackMetadataRequest` with fields:
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request for optional metadata clear those fields; `ImagePath` follows tri-state logic), and persists the DTO via `ITrackService.Update`. - `TrackName` (string, required)
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error. - `Artist` (string, required)
- `Album` (string?, optional)
- `Genre` (string?, optional)
- `ReleaseDate` (DateOnly?, optional)
- `ImagePath` (string?, tri-state: null = no change, "" = clear, value = set)
- `ReleaseType` (ReleaseType?, optional): updates the linked release if present; null = no change.
- `Medium` (ReleaseMedium?, optional): updates the linked release if present; null = no change. When `Medium` is set to non-`Cut`, also resets `ReleaseType` to `Single` (the DB default) to avoid stale studio-format values.
- `TrackNumber` (int?, optional): track position within the release; validated > 0 when provided.
- Looks up SQL row by ID, updates the provided fields, and persists via `ITrackService.Update`. Track-cardinal fields (`TrackName`, `TrackNumber`) update the track row; release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, `ReleaseType`, `Medium`) update the linked release (if present; loose tracks ignore these).
- Returns 200 on success. Returns 400 if `TrackNumber` ≤ 0 (when provided). Returns 404 if track not found. Returns 500 on update error.
## The image endpoints (two endpoints) ## The image endpoints (two endpoints)
@@ -126,6 +206,55 @@ Returns image bytes from the `images` vault.
- Streams the image file directly from disk without buffering. - Streams the image file directly from disk without buffering.
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`). - Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
## The release endpoints
### GET api/release (unauthenticated)
Paged release list, optionally filtered to one medium. Public browse data, same auth posture as `GET api/track/page`.
- **Query parameters**:
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). If provided, only releases of that medium are returned; the matching medium's metadata satellite is populated, others are null.
- `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): releases per page.
- `sortColumn` (string, optional): sort field (typically `"Title"`).
- `sortDescending` (bool, optional, default false): sort direction.
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
### GET api/release/{id:long} (unauthenticated)
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`.
- **Route parameter `id`** (long): the SQL release ID.
- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/release/{id:long}/mix/waveform (unauthenticated)
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault.
- **Route parameter `id`** (long): the SQL release ID.
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize])
Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform, store it in the `mix-waveforms` vault, and link it via `MixMetadata.WaveformEntryKey`. No request body.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- Calls `UnifiedReleaseService.TriggerMixWaveformAsync`.
- Returns 200 on success. Returns 404 if the release is missing, is not a Mix, has no track, or the track audio is not stored. Returns 500 on compute/storage failure.
### POST api/release/{id:long}/session/hero-image ([ApiKeyAuthorize])
Stores a hero image in the `images` vault and links it via `SessionMetadata.HeroImageEntryKey`. The release must be a Session medium (enforced in the service).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
- Validates MIME type (rejects unsupported types with `.bin` sentinel). Calls `UnifiedReleaseService.SetHeroImageAsync`.
- Returns 200 on success. Returns 400 for missing file or unsupported MIME type. Returns 404 if release not found. Returns 500 on processing or vault failure.
## ApiKey middleware behaviour ## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata. `ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
@@ -0,0 +1,181 @@
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Services;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReleaseController : ControllerBase
{
private readonly IReleaseService _releaseService;
private readonly UnifiedReleaseService _unifiedReleaseService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<ReleaseController> _logger;
public ReleaseController(
IReleaseService releaseService,
UnifiedReleaseService unifiedReleaseService,
WaveformProfileService waveformProfileService,
ILogger<ReleaseController> logger)
{
_releaseService = releaseService;
_unifiedReleaseService = unifiedReleaseService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
// GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
// Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching
// medium's metadata satellite is populated; the others are null. Backs the public /archive browser.
// Public browse data, same auth posture as GET api/track/page.
[HttpGet]
public async Task<ActionResult> GetReleases(
[FromQuery] string? medium = null,
[FromQuery] string? q = null,
[FromQuery] string? genre = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
CancellationToken ct = default)
{
ReleaseMedium? parsedMedium = null;
if (!string.IsNullOrWhiteSpace(medium))
{
if (!Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m))
return BadRequest($"Unrecognised medium: {medium}");
parsedMedium = m;
}
var filter = new ReleaseFilter { SearchText = q, Genre = genre };
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleases failed: {Error}", error);
return StatusCode(500, "Failed to load releases");
}
return Ok(result.Value);
}
// GET api/release/{id}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
// or no datum is stored. Declared before the shorter "{id:long}" route for clarity.
[HttpGet("{id:long}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
{
var lookup = await _releaseService.GetByIdAsync(id, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, "Failed to load release");
}
var release = lookup.Value;
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
{
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id);
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
// Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform,
// store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is
// missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/mix/waveform")]
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
{
var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct);
if (result.Success)
return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)
|| string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal)
|| string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal))
{
return NotFound();
}
_logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, error);
}
// POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart)
// Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release
// must be a Session medium (enforced in the service). Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/session/hero-image")]
[RequestSizeLimit(50_000_000)]
public async Task<ActionResult> UploadSessionHeroImage(
long id,
[FromForm] IFormFile? image,
CancellationToken ct = default)
{
if (image is null || image.Length == 0)
return BadRequest("Image file is required");
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
{
_logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct);
if (result.Success)
return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal))
return NotFound();
_logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, error);
}
// GET api/release/{id} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer
// "{id:long}/mix/waveform" routes so the segmented routes resolve first.
[HttpGet("{id:long}")]
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
{
var result = await _releaseService.GetByIdAsync(id, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, "Failed to load release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
}
+45 -3
View File
@@ -48,9 +48,10 @@ public class TrackController : ControllerBase
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route // These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId. // resolution never treats "page", "upload", or "meta" as a trackId.
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre= // GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}. // Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering). // q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
[HttpGet("page")] [HttpGet("page")]
public async Task<ActionResult> GetPage( public async Task<ActionResult> GetPage(
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -60,9 +61,10 @@ public class TrackController : ControllerBase
[FromQuery] string? q = null, [FromQuery] string? q = null,
[FromQuery] string? album = null, [FromQuery] string? album = null,
[FromQuery] string? genre = null, [FromQuery] string? genre = null,
[FromQuery] long? releaseId = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre }; var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
var effectiveFilter = filter.IsEmpty ? null : filter; var effectiveFilter = filter.IsEmpty ? null : filter;
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken); var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
@@ -191,6 +193,7 @@ public class TrackController : ControllerBase
[FromForm] string? originalFileName, [FromForm] string? originalFileName,
[FromForm] long createdByUserId, [FromForm] long createdByUserId,
[FromForm] string? releaseType, [FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber, [FromForm] int? trackNumber,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -242,6 +245,21 @@ public class TrackController : ControllerBase
if (!string.IsNullOrWhiteSpace(releaseType)) if (!string.IsNullOrWhiteSpace(releaseType))
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType); _logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
} }
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
ReleaseMedium parsedMedium;
if (!string.IsNullOrWhiteSpace(medium)
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
&& Enum.IsDefined(rm))
{
parsedMedium = rm;
}
else
{
parsedMedium = ReleaseMedium.Cut;
if (!string.IsNullOrWhiteSpace(medium))
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
}
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1; var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry // The processor router selects by extension and reads from disk, so the temp file must carry
@@ -269,6 +287,7 @@ public class TrackController : ControllerBase
createdByUserId, createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName, string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
parsedReleaseType, parsedReleaseType,
parsedMedium,
resolvedTrackNumber, resolvedTrackNumber,
cancellationToken); cancellationToken);
@@ -276,6 +295,15 @@ public class TrackController : ControllerBase
{ {
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio"; var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error); _logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
// A cardinality rejection is a well-formed request that violates a domain rule, so it
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is
// stripped so the client sees only the human-readable detail.
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
}
return StatusCode(500, error); return StatusCode(500, error);
} }
@@ -393,6 +421,20 @@ public class TrackController : ControllerBase
// ReleaseType is non-null on the release; null in the request means "no change". // ReleaseType is non-null on the release; null in the request means "no change".
if (request.ReleaseType is not null) if (request.ReleaseType is not null)
release.ReleaseType = request.ReleaseType.Value; release.ReleaseType = request.ReleaseType.Value;
// Medium is non-null on the release; null in the request means "no change".
if (request.Medium is not null)
{
release.Medium = request.Medium.Value;
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
// ReleaseType sent in the same request alongside a non-Cut medium.
if (request.Medium.Value != ReleaseMedium.Cut)
release.ReleaseType = ReleaseType.Single;
}
} }
var update = await _sqlTrackService.Update(track); var update = await _sqlTrackService.Update(track);
+466
View File
@@ -0,0 +1,466 @@
###############################################################################
# DeepDrftAPI — Phase 9 smoke tests
# IDE: VS 2022 / Rider / VS Code REST Client
#
# HOW TO USE:
# 1. Set @apiKey below. The real value is in DeepDrftAPI/environment/apikey.json
# under the key "ApiKeySettings.ApiKey". Do NOT commit a real key here.
# 2. Adjust @releaseId, @trackId, and @entryKey to IDs present in your DB.
# 3. Point the multipart upload requests at a real local file before sending.
#
# AUTH NOTE:
# Unauthenticated endpoints have no ApiKey header.
# ApiKey-gated endpoints use header "ApiKey: {{apiKey}}" (header name is literal
# "ApiKey", not "X-Api-Key" — confirmed in ApiKeyAuthenticationMiddleware.cs).
###############################################################################
@host = http://localhost:5003
# REPLACE with value from DeepDrftAPI/environment/apikey.json → ApiKeySettings.ApiKey
@apiKey = REPLACE_WITH_YOUR_API_KEY
# Placeholders — edit these to match IDs in your local DB
@releaseId = 1
@trackId = 1
@entryKey = replace-with-real-entry-key
###############################################################################
# 1. RELEASE READS
###############################################################################
### 1a. List all releases (unauth) — expect 200 PagedResult
# exercises: GET api/release (no medium filter)
GET {{host}}/api/release
Accept: application/json
###
### 1b. List Session releases (unauth) — expect 200, only Session medium returned
# exercises: GET api/release?medium=session
GET {{host}}/api/release?medium=session
Accept: application/json
###
### 1c. List Mix releases (unauth) — expect 200, only Mix medium returned
# exercises: GET api/release?medium=mix
GET {{host}}/api/release?medium=mix
Accept: application/json
###
### 1d. List Cut releases (unauth) — expect 200, only Cut medium returned
# exercises: GET api/release?medium=cut
GET {{host}}/api/release?medium=cut
Accept: application/json
###
### 1e. Bad medium value (unauth) — expect 400 "Unrecognised medium: bogus"
# exercises: GET api/release?medium=bogus → BadRequest branch in ReleaseController
GET {{host}}/api/release?medium=bogus
Accept: application/json
###
### 1f. Single release by id (unauth) — expect 200 ReleaseDto with both metadata navs
# exercises: GET api/release/{id:long}
GET {{host}}/api/release/{{releaseId}}
Accept: application/json
###
###############################################################################
# 2. TRACK READS
###############################################################################
### 2a. Paged track list — all tracks (unauth) — expect 200 PagedResult<TrackDto>
# exercises: GET api/track/page (no filters)
GET {{host}}/api/track/page
Accept: application/json
###
### 2b. Paged track list filtered by releaseId (unauth) — expect 200, tracks for that release
# exercises: GET api/track/page?releaseId=
GET {{host}}/api/track/page?releaseId={{releaseId}}
Accept: application/json
###
### 2c. Paged track list filtered by album title (unauth) — expect 200
# exercises: GET api/track/page?album=
GET {{host}}/api/track/page?album=My+Album+Name
Accept: application/json
###
### 2d. Paged track list filtered by genre (unauth) — expect 200
# exercises: GET api/track/page?genre=
GET {{host}}/api/track/page?genre=Electronic
Accept: application/json
###
### 2e. Single track metadata by SQL id (ApiKey) — expect 200 TrackDto
# exercises: GET api/track/meta/{id:long}
GET {{host}}/api/track/meta/{{trackId}}
ApiKey: {{apiKey}}
Accept: application/json
###
### 2f. Track audio stream — full file (unauth) — expect 200 with audio bytes
# exercises: GET api/track/{trackId} → streams WAV from FileDatabase vault
GET {{host}}/api/track/{{entryKey}}
Accept: audio/wav
###
### 2g. Track audio stream — Range request (unauth) — expect 206 Partial Content
# exercises: GET api/track/{trackId} with Range header → 206 + byte slice
GET {{host}}/api/track/{{entryKey}}
Range: bytes=44-
Accept: audio/wav
###
### 2h. Albums list (unauth) — expect 200 List<ReleaseDto> with per-release track counts
# exercises: GET api/track/albums
GET {{host}}/api/track/albums
Accept: application/json
###
### 2i. Genres list (unauth) — expect 200 List<string> distinct genres
# exercises: GET api/track/genres
GET {{host}}/api/track/genres
Accept: application/json
###
### 2j. Random track (unauth) — expect 200 TrackDto (or 404 when library is empty)
# exercises: GET api/track/random
GET {{host}}/api/track/random
Accept: application/json
###
### 2k. Waveform profile for track (unauth) — expect 200 WaveformProfileDto or 404 if not computed
# exercises: GET api/track/{trackId}/waveform
GET {{host}}/api/track/{{entryKey}}/waveform
Accept: application/json
###
### 2l. Waveform status for all tracks (ApiKey) — expect 200 List<WaveformStatusDto>
# exercises: GET api/track/waveform-status (admin backfill view)
GET {{host}}/api/track/waveform-status
ApiKey: {{apiKey}}
Accept: application/json
###
###############################################################################
# 3. MEDIUM WRITE PATH
###############################################################################
### 3a. Upload a Cut track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Cut
# REPLACE ./path/to/test.wav with a real local .wav (or .mp3 / .flac) file path.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Cut
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Test Album
--boundary
Content-Disposition: form-data; name="genre"
Electronic
--boundary
Content-Disposition: form-data; name="releaseDate"
2025-01-01
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Cut
--boundary
Content-Disposition: form-data; name="releaseType"
Single
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3b. Upload a Session track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Session
# NOTE: Session releases are single-track. Use a unique album name to create a new release.
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Session
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Session Album 001
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3c. Upload a Mix track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Mix
# NOTE: Mix releases are also single-track. Use a unique album name.
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Mix
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Mix Album 001
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Mix
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3d. Update track metadata — flip Medium to Session (ApiKey, JSON) — expect 200
# exercises: PUT api/track/meta/{id:long}
# Medium: null means "no change"; provide it to change the release medium.
PUT {{host}}/api/track/meta/{{trackId}}
ApiKey: {{apiKey}}
Content-Type: application/json
{
"TrackName": "Updated Track Name",
"Artist": "Updated Artist",
"Album": "Updated Album",
"Genre": "Electronic",
"ReleaseDate": "2025-06-01",
"ImagePath": null,
"ReleaseType": null,
"Medium": "Session",
"TrackNumber": 1
}
###
###############################################################################
# 4. WAVE 7 — SESSION CARDINALITY 409 SEQUENCE
#
# Session and Mix releases enforce a single-track maximum. The sequence below
# demonstrates the enforcement:
# Step 1: upload the FIRST track to a Session release → expect 200
# Step 2: upload a SECOND track to the SAME album+artist → expect 409 Conflict
#
# The 409 body will contain: "A Session release holds a single track; '<album>'
# already has one — edit the existing track or choose a different release."
#
# Cut-medium releases have no cardinality limit, so an identical sequence with
# medium=Cut would produce 200 on both requests.
###############################################################################
### 4a. [STEP 1] Upload first Session track — expect 200 TrackDto
# The album "Cardinality Test Session" does not yet exist; this creates it.
# REPLACE ./path/to/test.wav with a real local file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Cardinality Track 1
--boundary
Content-Disposition: form-data; name="artist"
Cardinality Artist
--boundary
Content-Disposition: form-data; name="album"
Cardinality Test Session
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 4b. [STEP 2] Upload second Session track to the SAME album+artist — expect 409 Conflict
# Same album "Cardinality Test Session", same artist "Cardinality Artist".
# UnifiedTrackService.UploadAsync pre-checks cardinality before the vault write.
# REPLACE ./path/to/test.wav with a real local file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Cardinality Track 2
--boundary
Content-Disposition: form-data; name="artist"
Cardinality Artist
--boundary
Content-Disposition: form-data; name="album"
Cardinality Test Session
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
2
--boundary--
###
###############################################################################
# 5. MIX WAVEFORM
###############################################################################
### 5a. Trigger waveform generation for a Mix release (ApiKey, no body) — expect 200 or 404
# exercises: POST api/release/{id:long}/mix/waveform
# 404 if the release is not a Mix, has no track, or the track has no audio stored.
POST {{host}}/api/release/{{releaseId}}/mix/waveform
ApiKey: {{apiKey}}
Content-Length: 0
###
### 5b. Fetch stored mix waveform (unauth) — expect 200 WaveformProfileDto or 404
# exercises: GET api/release/{id:long}/mix/waveform
# 404 when release is not a Mix, has no WaveformEntryKey, or datum not yet computed (run 5a first).
GET {{host}}/api/release/{{releaseId}}/mix/waveform
Accept: application/json
###
###############################################################################
# 6. SESSION HERO IMAGE
###############################################################################
### 6a. Upload hero image for a Session release (ApiKey, multipart) — expect 200 or 404
# exercises: POST api/release/{id:long}/session/hero-image
# 404 if release not found. 400 if no image file or unsupported content type.
# The release must be a Session (enforced in UnifiedReleaseService.SetHeroImageAsync).
# REPLACE ./path/to/hero.jpg with a real local JPEG or PNG file.
POST {{host}}/api/release/{{releaseId}}/session/hero-image
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="image"; filename="hero.jpg"
Content-Type: image/jpeg
< ./path/to/hero.jpg
--boundary--
###
@@ -19,4 +19,5 @@ public record UpdateTrackMetadataRequest(
DateOnly? ReleaseDate, DateOnly? ReleaseDate,
string? ImagePath = null, string? ImagePath = null,
ReleaseType? ReleaseType = null, ReleaseType? ReleaseType = null,
ReleaseMedium? Medium = null,
int? TrackNumber = null); int? TrackNumber = null);
+8
View File
@@ -64,6 +64,14 @@ builder.Services
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>()); .AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
builder.Services.AddScoped<UnifiedTrackService>(); builder.Services.AddScoped<UnifiedTrackService>();
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
builder.Services
.AddScoped<ReleaseRepository>()
.AddScoped<ReleaseManager>()
.AddScoped<IReleaseService>(sp => sp.GetRequiredService<ReleaseManager>());
builder.Services.AddScoped<UnifiedReleaseService>();
// AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the // AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the
// AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only // AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only
// web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds. // web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds.
@@ -0,0 +1,172 @@
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.Enums;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftAPI.Services;
/// <summary>
/// Host-internal orchestrator for the two release metadata write paths. Mirrors
/// <see cref="UnifiedTrackService"/>: it makes DeepDrftAPI the single authority over both the vault
/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller
/// coordinates the two stores.
/// </summary>
public class UnifiedReleaseService
{
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
// public-site MixWaveformVisualizer.
private const int MixWaveformBucketCount = 2048;
/// <summary>Error message returned when the Mix release has no linked track.</summary>
public const string MixHasNoTrackMessage = "Mix release has no track.";
/// <summary>Error message returned when the Mix track has no audio stored in the vault.</summary>
public const string MixTrackNoAudioMessage = "No audio stored for the Mix track.";
private readonly IReleaseService _releaseService;
private readonly FileDb _fileDatabase;
private readonly ImageProcessor _imageProcessor;
private readonly TrackContentService _trackContentService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<UnifiedReleaseService> _logger;
public UnifiedReleaseService(
IReleaseService releaseService,
FileDb fileDatabase,
ImageProcessor imageProcessor,
TrackContentService trackContentService,
WaveformProfileService waveformProfileService,
ILogger<UnifiedReleaseService> logger)
{
_releaseService = releaseService;
_fileDatabase = fileDatabase;
_imageProcessor = imageProcessor;
_trackContentService = trackContentService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
/// <summary>
/// Process a hero image into the Images vault, then point the release's Session satellite at it.
/// The medium check lives in <see cref="IReleaseService.SetSessionHeroImageAsync"/>: if the release
/// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable).
/// </summary>
public async Task<Result> SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct)
{
if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin")
{
_logger.LogWarning(
"SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}",
imageFile.ContentType, releaseId);
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
}
byte[] buffer;
await using (var stream = imageFile.OpenReadStream())
using (var memory = new MemoryStream())
{
await stream.CopyToAsync(memory, ct);
buffer = memory.ToArray();
}
var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType);
if (imageBinary is null)
{
_logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType);
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
}
var entryKey = Guid.NewGuid().ToString("N");
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
if (!stored)
{
_logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey);
return Result.CreateFailResult("Failed to store hero image.");
}
var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct);
if (!linked.Success)
{
// Vault write succeeded, SQL link failed — image is orphaned in the Images vault under
// entryKey. Log loudly (include entryKey) so it is recoverable manually.
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
entryKey, releaseId, error);
return linked;
}
return Result.CreatePassResult();
}
/// <summary>
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
/// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix
/// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
var lookup = await _releaseService.GetByIdAsync(releaseId, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error);
return Result.CreateFailResult("Failed to load release.");
}
if (lookup.Value is null)
return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage);
// Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a
// non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check
// is intentional — the orchestrator's guard is the cheap early-exit.
if (lookup.Value.Medium != ReleaseMedium.Mix)
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct);
if (!keysResult.Success || keysResult.Value is null)
{
var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error);
return Result.CreateFailResult("Failed to load release tracks.");
}
var entryKey = keysResult.Value.FirstOrDefault();
if (entryKey is null)
{
_logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId);
return Result.CreateFailResult(MixHasNoTrackMessage);
}
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
{
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
var computed = await _waveformProfileService.ComputeAndStoreAsync(
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
return Result.CreateFailResult("Failed to compute the Mix waveform.");
}
var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct);
if (!linked.Success)
{
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
entryKey, releaseId, error);
return linked;
}
return Result.CreatePassResult();
}
}
@@ -17,6 +17,14 @@ namespace DeepDrftAPI.Services;
public class UnifiedTrackService public class UnifiedTrackService
{ {
internal const string TrackNotFoundMessage = "Track not found."; internal const string TrackNotFoundMessage = "Track not found.";
/// <summary>
/// Stable marker prefixed onto a cardinality-rejection message so the controller can map this
/// specific failure to 409 Conflict (a well-formed request that violates a domain rule),
/// distinct from the 400 (malformed) and 500 (processing) paths. The human-readable detail
/// follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
private readonly TrackContentService _contentTrackContentService; private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService; private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase; private readonly FileDb _fileDatabase;
@@ -53,9 +61,38 @@ public class UnifiedTrackService
long createdByUserId, long createdByUserId,
string? originalFileName, string? originalFileName,
ReleaseType releaseType, ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber, int trackNumber,
CancellationToken ct) CancellationToken ct)
{ {
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
// find path can violate: a release that does not yet exist has zero tracks and admits its
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
// a future bounded medium is covered by the same line.
if (!string.IsNullOrWhiteSpace(album))
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
if (peek.Value is { } existing)
{
var cardinality = MediumRules.CardinalityOf(existing.Medium);
if (existing.TrackCount + 1 > cardinality.Max)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
$"'{existing.Title}' already has one — edit the existing track or choose a different release.");
}
}
}
var unpersisted = await _contentTrackContentService.AddTrackAsync( var unpersisted = await _contentTrackContentService.AddTrackAsync(
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName); tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
@@ -81,9 +118,15 @@ public class UnifiedTrackService
Genre = genre, Genre = genre,
ReleaseDate = releaseDate, ReleaseDate = releaseDate,
ReleaseType = releaseType, ReleaseType = releaseType,
Medium = medium,
CreatedByUserId = createdByUserId, CreatedByUserId = createdByUserId,
}; };
// Medium (like every other field in releaseData) applies only when this upload CREATES the
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
// subsequent track add: medium is a release-level property, changed only via the edit path
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct); var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null) if (!releaseResult.Success || releaseResult.Value is null)
{ {
+10
View File
@@ -43,6 +43,7 @@ namespace DeepDrftAPI
if (db is null) throw new Exception("Unable to initialize file database"); if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult(); InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult(); InitializeImageVault(db).GetAwaiter().GetResult();
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
return db; return db;
}); });
@@ -64,5 +65,14 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image); await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
} }
} }
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
{
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
}
}
} }
} }
@@ -20,4 +20,10 @@ public static class VaultConstants
/// from <c>TrackEntity.ImagePath</c>. /// from <c>TrackEntity.ImagePath</c>.
/// </summary> /// </summary>
public const string Images = "images"; public const string Images = "images";
/// <summary>
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
/// </summary>
public const string MixWaveforms = "mix-waveforms";
} }
@@ -39,12 +39,22 @@ public class WaveformProfileService
/// <summary> /// <summary>
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under /// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
/// <paramref name="entryKey"/>. Returns false (and logs) on any failure — a missing profile /// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
/// is handled gracefully downstream, so callers on the upload path log-and-continue rather /// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
/// than failing the upload. Does not throw for expected failure modes. /// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
/// </summary> /// </summary>
public async Task<bool> ComputeAndStoreAsync(ReadOnlyMemory<byte> wavBytes, string entryKey) public async Task<bool> ComputeAndStoreAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
int? bucketCount = null,
string? vaultName = null)
{ {
var effectiveBucketCount = bucketCount ?? _options.BucketCount;
var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles;
try try
{ {
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span); var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
@@ -62,15 +72,14 @@ public class WaveformProfileService
value.Channels, value.Channels,
value.SampleRate, value.SampleRate,
value.BitsPerSample, value.BitsPerSample,
_options.BucketCount); effectiveBucketCount);
var quantized = Quantize(profile); var quantized = Quantize(profile);
await EnsureVaultAsync(); await EnsureVaultAsync(effectiveVaultName);
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension)); var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync( var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary);
VaultConstants.WaveformProfiles, entryKey, binary);
if (!stored) if (!stored)
{ {
@@ -88,14 +97,15 @@ public class WaveformProfileService
} }
/// <summary> /// <summary>
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored /// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
/// (existing tracks predate profiling, and computation may have failed). Each byte is a /// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
/// peak-normalized loudness value in [0, 255]. /// is stored (existing tracks predate profiling, and computation may have failed). Each byte is
/// a peak-normalized loudness value in [0, 255].
/// </summary> /// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey) public async Task<byte[]?> GetProfileAsync(string entryKey, string? vaultName = null)
{ {
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>( var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
VaultConstants.WaveformProfiles, entryKey); vaultName ?? VaultConstants.WaveformProfiles, entryKey);
return binary?.Buffer; return binary?.Buffer;
} }
@@ -113,11 +123,11 @@ public class WaveformProfileService
return bytes; return bytes;
} }
private async Task EnsureVaultAsync() private async Task EnsureVaultAsync(string vaultName)
{ {
if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles)) if (!_fileDatabase.HasVault(vaultName))
{ {
await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media); await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media);
} }
} }
} }
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class MixMetadataConfiguration : BaseEntityConfiguration<MixMetadata>
{
public override void Configure(EntityTypeBuilder<MixMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("mix_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.WaveformEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("waveform_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.MixMetadata)
.HasForeignKey<MixMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_mix_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_mix_metadata_is_deleted");
}
}
@@ -42,6 +42,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnName("image_path"); .HasColumnName("image_path");
// ReleaseType is meaningful ONLY when Medium == Cut. It is the Cut medium's discriminator
// data and lives on the base table by deliberate, named exception:
// A CutMetadata satellite (mirroring SessionMetadata/MixMetadata) was considered and
// rejected. ReleaseType is read on every card of the /cuts browse — the highest-traffic
// read in the system. Moving it to a satellite would put a join on that hot path. So it
// stays here. Future media MUST NOT copy this pattern: the default is a satellite metadata
// table; this is the one allowed exception, justified solely by the /cuts read volume.
//
// The "ReleaseType only for Cut" invariant is advisory — enforced at the service layer and
// surfaced via the nullable ReleaseDto.ReleaseType (nulled for non-Cut at the converter).
// It is NOT a DB check constraint by choice, not necessity: EF supports HasCheckConstraint,
// but the invariant is advisory and we keep the schema free of it.
builder.Property(e => e.ReleaseType) builder.Property(e => e.ReleaseType)
.IsRequired() .IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal .HasConversion<string>() // Store as readable string, not int ordinal
@@ -49,6 +61,13 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasColumnName("release_type") .HasColumnName("release_type")
.HasDefaultValue(ReleaseType.Single); .HasDefaultValue(ReleaseType.Single);
builder.Property(e => e.Medium)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
.HasMaxLength(20)
.HasColumnName("medium")
.HasDefaultValue(ReleaseMedium.Cut); // Existing rows migrate to Cut with no data migration.
builder.Property(e => e.CreatedByUserId) builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id"); .HasColumnName("created_by_user_id");
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class SessionMetadataConfiguration : BaseEntityConfiguration<SessionMetadata>
{
public override void Configure(EntityTypeBuilder<SessionMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("session_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.HeroImageEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("hero_image_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.SessionMetadata)
.HasForeignKey<SessionMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_session_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_session_metadata_is_deleted");
}
}
+4
View File
@@ -12,6 +12,8 @@ public class DeepDrftContext : DbContext
public DbSet<TrackEntity> Tracks { get; set; } public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; } public DbSet<ReleaseEntity> Releases { get; set; }
public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -19,5 +21,7 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new TrackConfiguration()); modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
} }
} }
+31
View File
@@ -0,0 +1,31 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side release service. Repository outputs entities; this service outputs DTOs via TrackConverter.
/// Backs the medium-aware release read endpoints (paged list + by-id detail) and the two metadata
/// write paths (Session hero image, Mix waveform). The entity never escapes the service layer.
/// </summary>
public interface IReleaseService
{
/// <summary>Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing.</summary>
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default);
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
/// <summary>Find-or-create the Session satellite and set its hero image entry key. Fails when the release is not a Session.</summary>
Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default);
/// <summary>Find-or-create the Mix satellite and set its waveform entry key. Fails when the release is not a Mix.</summary>
Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default);
}
+9
View File
@@ -36,6 +36,15 @@ public interface ITrackService
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease( Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default); string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary>
/// Read-only peek for an existing release by its natural key, or null when none exists — a find
/// with no create side-effect. Backs the upload cardinality pre-check, which must read a release's
/// medium and live-track count before deciding whether to admit an upload, without creating a
/// release for an upload it may reject. The returned DTO carries TrackCount.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
string title, string artist, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack); Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track); Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id); Task<Result> Delete(long id);
@@ -0,0 +1,303 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260613013826_AddReleaseMedium")]
partial class AddReleaseMedium
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseMedium : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "medium",
table: "release",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Cut");
migrationBuilder.CreateTable(
name: "mix_metadata",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
waveform_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_mix_metadata", x => x.id);
table.ForeignKey(
name: "FK_mix_metadata_release_release_id",
column: x => x.release_id,
principalTable: "release",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "session_metadata",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
hero_image_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_session_metadata", x => x.id);
table.ForeignKey(
name: "FK_session_metadata_release_release_id",
column: x => x.release_id,
principalTable: "release",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_mix_metadata_is_deleted",
table: "mix_metadata",
column: "is_deleted");
migrationBuilder.CreateIndex(
name: "IX_mix_metadata_release_id",
table: "mix_metadata",
column: "release_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_session_metadata_is_deleted",
table: "session_metadata",
column: "is_deleted");
migrationBuilder.CreateIndex(
name: "IX_session_metadata_release_id",
table: "session_metadata",
column: "release_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mix_metadata");
migrationBuilder.DropTable(
name: "session_metadata");
migrationBuilder.DropColumn(
name: "medium",
table: "release");
}
}
}
@@ -22,6 +22,51 @@ namespace DeepDrftData.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -61,6 +106,14 @@ namespace DeepDrftData.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("is_deleted"); .HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate") b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date") .HasColumnType("date")
.HasColumnName("release_date"); .HasColumnName("release_date");
@@ -96,6 +149,51 @@ namespace DeepDrftData.Migrations
b.ToTable("release", (string)null); b.ToTable("release", (string)null);
}); });
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -156,6 +254,28 @@ namespace DeepDrftData.Migrations
b.ToTable("track", (string)null); b.ToTable("track", (string)null);
}); });
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b => modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{ {
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release") b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
@@ -168,6 +288,10 @@ namespace DeepDrftData.Migrations
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b => modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{ {
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks"); b.Navigation("Tracks");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
+149
View File
@@ -0,0 +1,149 @@
using System.Linq.Expressions;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side release service implementing <see cref="IReleaseService"/>. Deliberately does NOT extend
/// <c>Manager&lt;&gt;</c>: that CRUD base does not fit this read-projection + satellite-write purpose.
/// The layer boundary holds — ReleaseRepository outputs entities, this manager outputs DTOs via
/// TrackConverter, the single authoritative conversion path.
/// </summary>
public class ReleaseManager : IReleaseService
{
// Distinguishes "release does not exist" from a real failure so the controller can map to 404.
public const string ReleaseNotFoundMessage = "Release not found.";
private readonly ReleaseRepository _repository;
private readonly ILogger<ReleaseManager> _logger;
public ReleaseManager(ReleaseRepository repository, ILogger<ReleaseManager> logger)
{
_repository = repository;
_logger = logger;
}
// Nulls sort to end via the coalescing sentinels, matching TrackManager's convention.
private static Expression<Func<ReleaseEntity, object>> GetOrderExpression(string? sortColumn)
=> sortColumn switch
{
"Title" => r => r.Title,
"Artist" => r => r.Artist,
"ReleaseDate" => r => (object)(r.ReleaseDate ?? DateOnly.MaxValue),
"Medium" => r => r.Medium,
_ => r => r.Id
};
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
{
try
{
var parameters = new PagingParameters<ReleaseEntity>
{
Page = page,
PageSize = pageSize,
OrderBy = GetOrderExpression(sortColumn),
IsDescending = sortDescending,
};
// Collapse an all-null filter to null so the repository skips the predicate block entirely.
var effectiveFilter = filter is { IsEmpty: false } ? filter : null;
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, cancellationToken);
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
var dtos = entityPage.Items
.Select(r =>
{
var dto = TrackConverter.Convert(r);
dto.TrackCount = counts.GetValueOrDefault(r.Id);
return dto;
});
var dtoPage = PagedResult<ReleaseDto>.From(entityPage, dtos);
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(dtoPage);
}
catch (Exception e)
{
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByIdWithMetadataAsync(id, cancellationToken);
// TrackConverter nulls the non-matching satellite. TrackCount is not loaded for the detail
// read (the Tracks collection isn't Include'd) and is not needed by detail consumers.
return ResultContainer<ReleaseDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
{
try
{
var keys = await _repository.GetTrackEntryKeysByReleaseIdAsync(releaseId, cancellationToken);
return ResultContainer<List<string>>.CreatePassResult(keys);
}
catch (Exception e)
{
return ResultContainer<List<string>>.CreateFailResult(e.Message);
}
}
public async Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default)
{
try
{
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
if (release is null)
return Result.CreateFailResult(ReleaseNotFoundMessage);
if (release.Medium != ReleaseMedium.Session)
return Result.CreateFailResult($"Release {releaseId} is not a Session medium.");
await _repository.SetHeroImageEntryKeyAsync(releaseId, heroImageEntryKey, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
return Result.CreateFailResult(e.Message);
}
}
public async Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default)
{
try
{
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
if (release is null)
return Result.CreateFailResult(ReleaseNotFoundMessage);
if (release.Medium != ReleaseMedium.Mix)
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
await _repository.SetWaveformEntryKeyAsync(releaseId, waveformEntryKey, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
return Result.CreateFailResult(e.Message);
}
}
}
@@ -0,0 +1,162 @@
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
namespace DeepDrftData.Repositories;
/// <summary>
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository&lt;DeepDrftContext, ReleaseEntity&gt;</c>:
/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered)
/// and satellite-row management (Session/Mix metadata find-or-create). Injects <see cref="DeepDrftContext"/>
/// directly so reads/writes stay in one unit of work.
/// </summary>
public class ReleaseRepository
{
private readonly DeepDrftContext _context;
private readonly ILogger<ReleaseRepository> _logger;
public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> logger)
{
_context = context;
_logger = logger;
}
// Single location where the medium↔metadata correlation is determined on a list read: a satellite
// is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity)
// enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a
// non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads.
private static IQueryable<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
=> medium switch
{
ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
_ => query
};
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
// medium's satellite is Include'd; total count reflects every applied predicate (all before
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
// browse path searches and filters identically to the track path.
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
PagingParameters<ReleaseEntity> paging,
ReleaseMedium? medium,
ReleaseFilter? filter,
CancellationToken ct)
{
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
if (medium.HasValue)
query = query.Where(r => r.Medium == medium.Value);
if (filter is not null)
{
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
// on the release itself, so no navigation guard is needed (unlike the track path).
var pattern = $"%{filter.SearchText}%";
query = query.Where(r =>
EF.Functions.ILike(r.Title, pattern)
|| EF.Functions.ILike(r.Artist, pattern));
}
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(r => r.Genre == filter.Genre);
}
query = ApplyMediumInclude(query, medium);
var totalCount = await query.CountAsync(ct);
if (paging.OrderBy is not null)
query = paging.IsDescending ? query.OrderByDescending(paging.OrderBy) : query.OrderBy(paging.OrderBy);
var items = await query.Skip(paging.Skip).Take(paging.PageSize).ToListAsync(ct);
return new PagedResult<ReleaseEntity>
{
Items = items,
TotalCount = totalCount,
Page = paging.Page,
PageSize = paging.PageSize,
};
}
// Single release with both satellites Include'd: the medium is unknown until fetched, and both are
// 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary.
public async Task<ReleaseEntity?> GetByIdWithMetadataAsync(long id, CancellationToken ct)
=> await _context.Releases
.Where(r => r.Id == id && !r.IsDeleted)
.Include(r => r.SessionMetadata)
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
IEnumerable<long> releaseIds,
CancellationToken ct)
{
var ids = releaseIds.ToList();
return await _context.Tracks
.Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value))
.GroupBy(t => t.ReleaseId!.Value)
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
}
// Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for
// Session/Mix (enforced at upload); may be multiple for Cut.
public async Task<List<string>> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct)
=> await _context.Tracks
.Where(t => !t.IsDeleted && t.ReleaseId == releaseId)
.OrderBy(t => t.TrackNumber)
.Select(t => t.EntryKey)
.ToListAsync(ct);
// Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK
// makes (ReleaseId) the natural key; a repeat call updates the existing row in place.
public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct)
{
var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct);
if (existing is not null)
{
existing.HeroImageEntryKey = heroImageEntryKey;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
return;
}
_context.SessionMetadata.Add(new SessionMetadata
{
ReleaseId = releaseId,
HeroImageEntryKey = heroImageEntryKey,
});
await _context.SaveChangesAsync(ct);
}
// Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create
// pattern as SetHeroImageEntryKeyAsync.
public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct)
{
var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct);
if (existing is not null)
{
existing.WaveformEntryKey = waveformEntryKey;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
return;
}
_context.MixMetadata.Add(new MixMetadata
{
ReleaseId = releaseId,
WaveformEntryKey = waveformEntryKey,
});
await _context.SaveChangesAsync(ct);
}
}
@@ -95,6 +95,11 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
if (!string.IsNullOrWhiteSpace(filter.Genre)) if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre); query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
// Exact release-id join. ReleaseId is a column on the track itself, so this needs no
// navigation guard — it is the authoritative alternative to the Album title match.
if (filter.ReleaseId is { } releaseId)
query = query.Where(t => t.ReleaseId == releaseId);
} }
var totalCount = await query.CountAsync(ct); var totalCount = await query.CountAsync(ct);
+23 -2
View File
@@ -1,5 +1,6 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Models.Converters; using Models.Converters;
namespace DeepDrftData; namespace DeepDrftData;
@@ -25,7 +26,24 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = entity.Genre, Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate, ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath, ImagePath = entity.ImagePath,
ReleaseType = entity.ReleaseType, Medium = entity.Medium,
// ReleaseType is meaningful only for Cut; null it for Session/Mix at the mapping point so no
// consumer mistakes a stale studio-format value for a live/mix release.
ReleaseType = entity.Medium == ReleaseMedium.Cut ? entity.ReleaseType : (ReleaseType?)null,
SessionMetadata = entity.SessionMetadata is null
? null
: new SessionMetadataDto
{
ReleaseId = entity.SessionMetadata.ReleaseId,
HeroImageEntryKey = entity.SessionMetadata.HeroImageEntryKey
},
MixMetadata = entity.MixMetadata is null
? null
: new MixMetadataDto
{
ReleaseId = entity.MixMetadata.ReleaseId,
WaveformEntryKey = entity.MixMetadata.WaveformEntryKey
},
CreatedByUserId = entity.CreatedByUserId CreatedByUserId = entity.CreatedByUserId
}; };
@@ -39,7 +57,10 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
Genre = dto.Genre, Genre = dto.Genre,
ReleaseDate = dto.ReleaseDate, ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath, ImagePath = dto.ImagePath,
ReleaseType = dto.ReleaseType, Medium = dto.Medium,
// Entity ReleaseType is non-nullable; default back to Single when the DTO nulled it for a
// non-Cut release. Primarily a write-path reconstruction concern.
ReleaseType = dto.ReleaseType ?? ReleaseType.Single,
CreatedByUserId = dto.CreatedByUserId CreatedByUserId = dto.CreatedByUserId
}; };
+24 -1
View File
@@ -3,6 +3,7 @@ using Data.Managers;
using DeepDrftData.Repositories; using DeepDrftData.Repositories;
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Models.Common; using Models.Common;
using NetBlocks.Models; using NetBlocks.Models;
@@ -200,6 +201,25 @@ public class TrackManager
} }
} }
public async Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
string title, string artist, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is null)
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
var dto = TrackConverter.Convert(existing);
dto.TrackCount = await Repository.CountLiveTracksByReleaseAsync(existing.Id, cancellationToken);
return ResultContainer<ReleaseDto?>.CreatePassResult(dto);
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default) public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
{ {
try try
@@ -263,7 +283,10 @@ public class TrackManager
releaseEntity.Genre = release.Genre; releaseEntity.Genre = release.Genre;
releaseEntity.ReleaseDate = release.ReleaseDate; releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath; releaseEntity.ImagePath = release.ImagePath;
releaseEntity.ReleaseType = release.ReleaseType; releaseEntity.Medium = release.Medium;
// DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not.
// Default to Single when null, matching TrackConverter.Convert(ReleaseDto).
releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single;
releaseEntity.CreatedByUserId = release.CreatedByUserId; releaseEntity.CreatedByUserId = release.CreatedByUserId;
await Repository.UpdateReleaseAsync(releaseEntity); await Repository.UpdateReleaseAsync(releaseEntity);
} }
@@ -5,7 +5,7 @@
<MudGrid> <MudGrid>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))" <MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
T="string" Label="Album Name" Required="true" RequiredError="Album Name is required" T="string" Label="Release Name" Required="true" RequiredError="Release Name is required"
Variant="Variant.Outlined" Disabled="Disabled" /> Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
@@ -22,15 +22,6 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" /> Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem> </MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseType" Value="ReleaseType" ValueChanged="@((ReleaseType v) => ReleaseTypeChanged.InvokeAsync(v))"
Label="Release Type" Variant="Variant.Outlined" Disabled="Disabled">
@foreach (var rt in Enum.GetValues<ReleaseType>())
{
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false"> <MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3"> <MudStack Spacing="3">
@@ -70,6 +61,15 @@
</MudField> </MudField>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudDivider Class="my-4" />
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
</MudPaper> </MudPaper>
@code { @code {
@@ -83,9 +83,20 @@
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; } [Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; [Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; } [Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
[Parameter] public IBrowserFile? SelectedImageFile { get; set; } [Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; } [Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
// Session-only — the held hero-image file, threaded through MediumFields to SessionFields.
// Ignored for Cut/Mix media. The parent (BatchUpload) owns it and uploads it after create.
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// Gates the hero file picker in SessionFields (threaded to MediumFields → SessionFields).
// Set true only on the BatchUpload create path; leave false/absent on all edit paths.
[Parameter] public bool AllowHeroUpload { get; set; }
// BatchEdit only: when set (and no new file picked), preview the release's current cover. // BatchEdit only: when set (and no new file picked), preview the release's current cover.
// The parent nulls this to drop the preview when the admin clears the existing cover. // The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; } [Parameter] public string? ExistingImagePath { get; set; }
@@ -98,6 +109,20 @@
? null ? null
: $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}"; : $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}";
// MediumFields uses two-way @bind; bridge its bindings to this component's own
// parameter/EventCallback pairs so the parent form stays the single owner of the values.
private ReleaseMedium MediumBinding
{
get => Medium;
set => MediumChanged.InvokeAsync(value);
}
private ReleaseType ReleaseTypeBinding
{
get => ReleaseType;
set => ReleaseTypeChanged.InvokeAsync(value);
}
private Task HandleImageFileSelected(InputFileChangeEventArgs e) => private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
SelectedImageFileChanged.InvokeAsync(e.File); SelectedImageFileChanged.InvokeAsync(e.File);
@@ -34,6 +34,8 @@
@bind-Genre="_genre" @bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate" @bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType" @bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile" @bind-SelectedImageFile="_selectedImageFile"
ExistingImagePath="_existingImagePath" ExistingImagePath="_existingImagePath"
Disabled="_saving" /> Disabled="_saving" />
@@ -51,11 +53,15 @@
</MudStack> </MudStack>
} }
@* Session/Mix are single-track releases (§9.3): suppress the add-track affordance and keep the
list collapsed to one row — OnMediumChanged trims rows 2..n when the medium switches to a
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
<MudGrid> <MudGrid>
<MudItem xs="12" md="5"> <MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks" <BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex" @bind-SelectedIndex="_selectedIndex"
Disabled="_saving" Disabled="_saving"
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
OnWavFilesSelected="HandleWavFilesSelected" OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp" OnMoveUp="MoveUp"
OnMoveDown="MoveDown" OnMoveDown="MoveDown"
@@ -66,6 +72,7 @@
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)" <BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_saving" Disabled="_saving"
ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" /> TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@@ -124,6 +131,26 @@
private string _genre = string.Empty; private string _genre = string.Empty;
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single; private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
// default server-side for a non-Cut medium.
//
// Switching to a single-track medium collapses any multi-track list to the first row so the
// single-track invariant (§9.3) holds before save — the same collapse BatchUpload.OnMediumChanged
// performs, reading the same MediumRules cardinality the upload service enforces. Dropping rows
// 2..n is an in-memory trim only; existing tracks are not deleted server-side (RemoveRow owns
// deletion), so the hidden rows simply fall out of this edit session.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -155,6 +182,7 @@
_genre = release?.Genre ?? string.Empty; _genre = release?.Genre ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty; _releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single; _releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
_existingImagePath = release?.ImagePath; _existingImagePath = release?.ImagePath;
_tracks = tracks.Select(t => new BatchRowModel _tracks = tracks.Select(t => new BatchRowModel
@@ -168,6 +196,13 @@
Status = BatchRowStatus.Queued Status = BatchRowStatus.Queued
}).ToList(); }).ToList();
// Same single-track collapse on the load path, via the shared MediumRules declaration: a
// release whose stored medium is single-track surfaces only its first row for editing.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
}
_selectedIndex = _tracks.Count > 0 ? 0 : -1; _selectedIndex = _tracks.Count > 0 ? 0 : -1;
_loading = false; _loading = false;
} }
@@ -254,7 +289,7 @@
if (string.IsNullOrWhiteSpace(_albumName)) if (string.IsNullOrWhiteSpace(_albumName))
{ {
_errorMessage = "Album Name is required."; _errorMessage = "Release Name is required.";
return; return;
} }
@@ -303,6 +338,14 @@
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName; var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre; var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
// carry through to the stored track name.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
{
_tracks[0].TrackName = _albumName;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt. _imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_saving = true; _saving = true;
_processedCount = 0; _processedCount = 0;
@@ -361,6 +404,7 @@
releaseDate, releaseDate,
imagePathForUpdate, imagePathForUpdate,
_releaseType, _releaseType,
_medium,
trackNumber); trackNumber);
if (!updateResult.Success) if (!updateResult.Success)
@@ -395,7 +439,8 @@
row.WavFile.Name, row.WavFile.Name,
createdByUserId, createdByUserId,
_releaseType, _releaseType,
trackNumber); trackNumber,
_medium);
if (!uploadResult.Success || uploadResult.Value is null) if (!uploadResult.Success || uploadResult.Value is null)
{ {
@@ -421,6 +466,7 @@
releaseDate, releaseDate,
linkPath, linkPath,
_releaseType, _releaseType,
_medium,
trackNumber); trackNumber);
if (!linkResult.Success) if (!linkResult.Success)
@@ -5,14 +5,17 @@
else else
{ {
<MudStack Spacing="4"> <MudStack Spacing="4">
<MudTextField Value="SelectedTrack.TrackName" @if (ShowTrackName)
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))" {
T="string" <MudTextField Value="SelectedTrack.TrackName"
Label="Track Name" ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
Required="true" T="string"
RequiredError="Track Name is required" Label="Track Name"
Variant="Variant.Outlined" Required="true"
Disabled="Disabled" /> RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="Disabled" />
}
@if (SelectedTrack.Id.HasValue) @if (SelectedTrack.Id.HasValue)
{ {
@@ -46,6 +49,12 @@ else
[Parameter] public BatchRowModel? SelectedTrack { get; set; } [Parameter] public BatchRowModel? SelectedTrack { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
[Parameter] public EventCallback<string> TrackNameChanged { get; set; } [Parameter] public EventCallback<string> TrackNameChanged { get; set; }
/// <summary>
/// When false (single-track Session/Mix), the Track Name field is suppressed — the name is
/// derived from the Release Name by the parent form and never entered independently.
/// Defaults to true so the Cut multi-track path is unchanged.
/// </summary>
[Parameter] public bool ShowTrackName { get; set; } = true;
private static string FormatBytes(long bytes) private static string FormatBytes(long bytes)
{ {
@@ -6,6 +6,7 @@
@attribute [Authorize] @attribute [Authorize]
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -22,34 +23,61 @@
@bind-Genre="_genre" @bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate" @bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType" @bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile" @bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" /> Disabled="_uploading" />
<MudGrid> @if (_medium == ReleaseMedium.Cut)
<MudItem xs="12" md="5"> {
<BatchTrackList Tracks="_tracks" <MudGrid>
@bind-SelectedIndex="_selectedIndex" <MudItem xs="12" md="5">
Disabled="_uploading" <BatchTrackList Tracks="_tracks"
OnWavFilesSelected="HandleWavFilesSelected" @bind-SelectedIndex="_selectedIndex"
OnMoveUp="MoveUp" Disabled="_uploading"
OnMoveDown="MoveDown" OnWavFilesSelected="HandleWavFilesSelected"
OnRemove="RemoveRow" /> OnMoveUp="MoveUp"
</MudItem> OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7"> <MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)" <BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading" Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" /> TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
}
else
{
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
<MudPaper Class="pa-4" Elevation="2">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">Track</MudText>
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
@if (_tracks.Count > 0)
{
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
}
</MudStack>
</MudPaper>
}
@if (!string.IsNullOrEmpty(_errorMessage)) @if (!string.IsNullOrEmpty(_errorMessage))
{ {
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert> <MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
} }
@if (!string.IsNullOrEmpty(_warningMessage))
{
<MudAlert Severity="Severity.Warning" Class="mt-4">@_warningMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4"> <MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text" <MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))" OnClick="@(() => Navigation.NavigateTo("/tracks"))"
@@ -83,15 +111,76 @@
private bool _uploading; private bool _uploading;
private int _uploadedCount; private int _uploadedCount;
private string? _errorMessage; private string? _errorMessage;
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
private string? _warningMessage;
private IBrowserFile? _selectedImageFile; private IBrowserFile? _selectedImageFile;
private string? _imagePath; private string? _imagePath;
// Session-only: the hero image is resource-addressed and cannot be uploaded until the release
// exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create.
private IBrowserFile? _heroImageFile;
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
private bool _heroWarningAcknowledged;
private string _albumName = string.Empty; private string _albumName = string.Empty;
private string _artist = string.Empty; private string _artist = string.Empty;
private string _genre = string.Empty; private string _genre = string.Empty;
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single; private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// Optional pre-select from the Add-Track buttons (§8.E): /tracks/upload?medium=session lands the
// form already in Session mode. A seed only — the medium selector stays user-changeable after load.
// Unrecognised/absent values fall through to the Cut default (same defensive posture as the API's
// TrackController.UploadTrack medium parse).
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
protected override void OnInitialized()
{
// Seed the medium from the query param so a pre-selected upload form (e.g. the Sessions tab's
// Add Track) lands already showing that medium's conditional fields. Goes through OnMediumChanged
// so the single-track collapse runs identically to a user selector change.
if (!string.IsNullOrWhiteSpace(MediumParam)
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
&& Enum.IsDefined(medium))
{
OnMediumChanged(medium);
}
}
// Switching to a single-track medium collapses any multi-track selection to the first row so the
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
// declaration the upload service enforces, so the form and the domain cannot drift.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
_tracks.Clear();
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
_selectedIndex = 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files) private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{ {
@@ -143,10 +232,11 @@
private async Task SubmitAsync() private async Task SubmitAsync()
{ {
_errorMessage = null; _errorMessage = null;
_warningMessage = null;
if (string.IsNullOrWhiteSpace(_albumName)) if (string.IsNullOrWhiteSpace(_albumName))
{ {
_errorMessage = "Album Name is required."; _errorMessage = "Release Name is required.";
return; return;
} }
@@ -189,6 +279,25 @@
return; return;
} }
// A Session's hero is its primary visual identity on the public detail page. It is optional —
// a Session can be authored without one and set later from the Sessions browser — but a missing
// hero is usually an oversight, so warn (do not block). The first submit without a hero shows the
// warning and primes acknowledgment; a second submit proceeds.
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
{
_heroWarningAcknowledged = true;
_warningMessage = "No hero image selected. A Session usually needs one — you can add it now, "
+ "or submit again to create the Session without it (set the hero later from the Sessions browser).";
return;
}
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
{
_tracks[0].TrackName = _albumName;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt. _imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_uploading = true; _uploading = true;
_uploadedCount = 0; _uploadedCount = 0;
@@ -237,7 +346,8 @@
row.WavFile.Name, row.WavFile.Name,
createdByUserId, createdByUserId,
_releaseType, _releaseType,
trackNumber); trackNumber,
_medium);
if (!result.Success || result.Value is null) if (!result.Success || result.Value is null)
{ {
@@ -262,6 +372,7 @@
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"), string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath, imgPath,
_releaseType, _releaseType,
_medium,
trackNumber); trackNumber);
if (!linkResult.Success) if (!linkResult.Success)
@@ -272,6 +383,58 @@
} }
} }
// Session hero image is resource-addressed, so it is uploaded here — after the
// release exists and we have its id — within the same submit gesture. Non-blocking:
// the Session is persisted; a failed hero upload is recoverable from the Sessions
// browser's per-row Set/Replace hero action.
if (_medium == ReleaseMedium.Session
&& _heroImageFile is { } heroFile
&& result.Value.ReleaseId is { } sessionReleaseId)
{
try
{
await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000);
var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync(
sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType);
if (!heroResult.Success)
{
var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}",
sessionReleaseId, row.TrackName, heroError);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
catch (Exception heroEx)
{
Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
else if (_medium == ReleaseMedium.Session && _heroImageFile is not null)
{
// ReleaseId was null on a Session track result — internal inconsistency.
// Hero file is held but cannot be uploaded without a release id; log and
// surface so the admin can set it from the Sessions browser.
Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped",
row.TrackName, result.Value.Id);
Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning);
}
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
// computes nothing — the API derives the datum from the audio it just stored.
// Non-blocking: the track is persisted; a failed trigger is recoverable from
// the Mixes browser's per-row Generate action.
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
mixReleaseId, row.TrackName);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
row.Status = BatchRowStatus.Done; row.Status = BatchRowStatus.Done;
succeeded++; succeeded++;
} }
@@ -1,6 +1,7 @@
@using System.Net @using System.Net
@using DeepDrftManager.Services @using DeepDrftManager.Services
@using DeepDrftModels.DTOs @using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject IDialogService DialogService @inject IDialogService DialogService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -56,10 +57,18 @@ else
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd> <MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd> <MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Type"> <MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@context.Release.ReleaseType</MudChip> <MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">
@(context.Release.Medium == ReleaseMedium.Cut
? context.Release.ReleaseType?.ToString() ?? "—"
: MediumTypeLabels[context.Release.Medium])
</MudChip>
</MudTd> </MudTd>
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd> <MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
<MudTd DataLabel="Actions"> <MudTd DataLabel="Actions">
@* Medium-specific row action (Session hero, Mix waveform) when a host supplies one;
the ALL tab supplies none. Rendered before the shared edit/delete so the medium
affordance reads left-to-right ahead of the universal actions. *@
@RowActions?.Invoke(context.Release)
<MudTooltip Text="Batch Edit"> <MudTooltip Text="Batch Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit" <MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small" Size="Size.Small"
@@ -117,17 +126,25 @@ else
[Parameter] public bool IsLoading { get; set; } [Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; } [Parameter] public EventCallback OnReleasesChanged { get; set; }
// Optional per-row, medium-specific action slot (Session hero upload, Mix waveform generate),
// rendered in the Actions cell ahead of the shared edit/delete buttons. The ALL tab leaves it
// unset and renders the grid exactly as before. A per-medium host (CmsCut/Session/MixBrowser)
// supplies it so the rich grid filtered to one medium keeps that medium's bespoke affordance —
// the rich expand/delete/Type-chip/edit logic stays here, single-sourced, rather than forked.
[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }
private List<AlbumRow> _rows = new(); private List<AlbumRow> _rows = new();
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet // Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is // resurrecting a row we removed locally on delete: while the parent holds the same Releases
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album // instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new
// back. We only re-project when the parent hands us a genuinely new list. // list), a blind rebuild every render would bring the deleted row back. We only re-project when
// the parent hands us a genuinely new list.
private IReadOnlyList<ReleaseDto>? _projectedReleases; private IReadOnlyList<ReleaseDto>? _projectedReleases;
// Re-project rows only when the parent supplies a genuinely new release list (reference change). // Re-project rows only when the parent supplies a genuinely new release list (reference change).
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the // Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
// same cached VM.Albums instance. // same Releases instance.
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
if (!ReferenceEquals(_projectedReleases, Releases)) if (!ReferenceEquals(_projectedReleases, Releases))
@@ -141,6 +158,15 @@ else
private static string ThumbUrl(string imagePath) => private static string ThumbUrl(string imagePath) =>
$"/api/image/{Uri.EscapeDataString(imagePath)}"; $"/api/image/{Uri.EscapeDataString(imagePath)}";
// Medium → Type-chip display label for non-Cut media. Cut rows show ReleaseType instead.
// One entry per non-Cut medium; a future medium adds one line here, no markup change needed.
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTypeLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Session] = "Session",
[ReleaseMedium.Mix] = "DJ Mix",
};
private async Task ToggleExpand(AlbumRow row) private async Task ToggleExpand(AlbumRow row)
{ {
row.IsExpanded = !row.IsExpanded; row.IsExpanded = !row.IsExpanded;
@@ -231,7 +257,7 @@ else
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade // Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the // delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
// parent so the cached VM.Albums stays in sync with what is shown. // parent so its release list stays in sync with what is shown.
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row) private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{ {
var confirmed = await DialogService.ShowMessageBox( var confirmed = await DialogService.ShowMessageBox(
@@ -0,0 +1,55 @@
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar
@* The ALL-tab content (Phase 9 §8.B): the cross-medium all-releases grid (CUTS, SESSIONS, MIXES
together) with per-row edit, delete, expand-tracks, and the 8.D Type chip. Self-contained — owns its
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
sync with the catalogue. *@
<CmsAlbumBrowser Releases="_releases"
IsLoading="_loading"
OnReleasesChanged="OnGridReleasesChanged" />
@code {
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
// catalogue — e.g. TrackList's genre cache. The grid refreshes its own list regardless; this is a
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
[Parameter] public EventCallback OnReleasesChanged { get; set; }
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
private bool _loading = true;
protected override Task OnInitializedAsync() => ReloadAsync();
private async Task OnGridReleasesChanged()
{
await ReloadAsync();
await OnReleasesChanged.InvokeAsync();
}
// Single load path: the initial fetch and the post-mutation refresh both run through here. After a
// delete CmsAlbumBrowser has already dropped the row from its own projection, so this re-fetch
// reconciles the authoritative list (track counts, orphaned-release cleanup) without a stale cache.
private async Task ReloadAsync()
{
_loading = true;
StateHasChanged();
var result = await CmsTrackService.GetReleasesAsync();
if (result.Success && result.Value is not null)
{
_releases = result.Value;
}
else
{
_releases = Array.Empty<ReleaseDto>();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load releases: {error}", Severity.Error);
}
_loading = false;
StateHasChanged();
}
}
@@ -0,0 +1,24 @@
@inherits CmsMediumBrowserBase<CmsCutBrowser.CutRow>
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
forked grid. Cuts have no medium-specific row action, so no RowActions slot is supplied; the grid
renders its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
<CmsAlbumBrowser Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync" />
@code {
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
protected override string MediumNoun => "cuts";
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
protected override ReleaseDto ReleaseOf(CutRow row) => row.Release;
public sealed class CutRow
{
public required ReleaseDto Release { get; set; }
}
}
@@ -0,0 +1,95 @@
using DeepDrftManager.Services;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftManager.Components.Pages.Tracks;
/// <summary>
/// Shared fetch + state logic for the per-medium browsers (Cuts, Sessions, Mixes). Each subclass feeds
/// the rich <c>CmsAlbumBrowser</c> grid a medium-filtered release list, so the per-medium tabs gain the
/// same expand-tracks / delete / Type-chip / edit behaviour as the ALL tab without re-implementing any of
/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the
/// per-release row projection, and a cover-thumbnail helper; subclasses supply the <see cref="Medium"/>,
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
/// rich grid's <c>RowActions</c> slot, looking their action-state row up with <see cref="RowFor"/>.
/// </summary>
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
/// <see cref="Releases"/> projection; <typeparamref name="TRow"/> only carries the action state.</typeparam>
public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : class
{
[Inject] public required ICmsReleaseService CmsReleaseService { get; set; }
[Inject] public required ISnackbar Snackbar { get; set; }
/// <summary>The medium this browser lists. Subclass-supplied constant.</summary>
protected abstract ReleaseMedium Medium { get; }
/// <summary>Plural noun for this medium used in error text (e.g. "sessions", "mixes").</summary>
protected abstract string MediumNoun { get; }
/// <summary>Projects a fetched release into the subclass's row model.</summary>
protected abstract TRow ToRow(ReleaseDto release);
/// <summary>The release carried by a subclass row, for keying the action-state lookup.</summary>
protected abstract ReleaseDto ReleaseOf(TRow row);
protected List<TRow> Rows { get; private set; } = new();
protected bool Loading { get; private set; } = true;
// Bare release projection handed to the rich grid. The grid does the expand/delete/edit/Type-chip;
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
// release.Id → action-state row, so a RowActions fragment (which the grid hands a ReleaseDto) can
// recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
private Dictionary<long, TRow> _rowsById = new();
protected override async Task OnInitializedAsync() => await LoadAsync();
/// <summary>Recovers the action-state row for a release the rich grid is rendering. Null if the
/// release is not in the current page (e.g. just deleted), in which case the action is skipped.</summary>
protected TRow? RowFor(ReleaseDto release) =>
_rowsById.TryGetValue(release.Id, out var row) ? row : null;
/// <summary>
/// Reloads the medium-filtered release list. Wired to the rich grid's <c>OnReleasesChanged</c> so a
/// delete re-fetches the authoritative list (track counts, orphan cleanup) — the same single-load
/// posture <c>CmsAllReleasesGrid</c> uses for the ALL tab.
/// </summary>
protected async Task ReloadAsync()
{
await LoadAsync();
StateHasChanged();
}
private async Task LoadAsync()
{
Loading = true;
// Single-track releases; a single generous page covers the CMS catalogue (same small-catalogue
// assumption the album browser makes).
var result = await CmsReleaseService.GetPagedAsync(
Medium, page: 1, pageSize: 100,
sortColumn: "Title", sortDescending: false);
if (result.Success && result.Value is not null)
{
Rows = result.Value.Items.Select(ToRow).ToList();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load {MediumNoun}: {error}", Severity.Error);
Rows = new List<TRow>();
}
Releases = Rows.Select(ReleaseOf).ToList();
_rowsById = Rows.ToDictionary(r => ReleaseOf(r).Id);
Loading = false;
}
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
protected static string ThumbUrl(string entryKey) =>
$"/api/image/{Uri.EscapeDataString(entryKey)}";
}
@@ -0,0 +1,135 @@
@page "/tracks/mixes"
@inherits CmsMediumBrowserBase<CmsMixBrowser.MixRow>
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ILogger<CmsMixBrowser> Logger
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
/tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C
parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as
its medium-specific RowActions slot so that affordance survives the move off the thin table. When
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved in both. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
@GridContent
</MudContainer>
}
@code {
/// <summary>
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
/// chrome (title, container, back button). False (default) renders the full routable page.
/// </summary>
[Parameter] public bool Embedded { get; set; }
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
protected override MixRow ToRow(ReleaseDto release) => new()
{
Release = release,
HasWaveform = !string.IsNullOrEmpty(release.MixMetadata?.WaveformEntryKey)
};
protected override ReleaseDto ReleaseOf(MixRow row) => row.Release;
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
// both branches above render the same markup without duplication. The waveform generate is the Mix's
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
// matching MixRow's generate state.
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync">
<RowActions Context="release">
@{ var row = RowFor(release); }
@if (row is not null)
{
@if (row.HasWaveform)
{
<MudTooltip Text="Waveform generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
else
{
<MudTooltip Text="No waveform — incomplete">
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
</MudTooltip>
}
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@row.IsGenerating"
OnClick="@(() => GenerateWaveformAsync(row))">
@if (row.IsGenerating)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating…</span>
}
else
{
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
}
</MudButton>
}
</RowActions>
</CmsAlbumBrowser>;
private async Task GenerateWaveformAsync(MixRow row)
{
row.IsGenerating = true;
StateHasChanged();
try
{
var result = await CmsReleaseService.GenerateMixWaveformAsync(row.Release.Id);
if (result.Success)
{
// Optimistic update: the trigger succeeded, so the waveform is stored. Unlike SessionBrowser's
// re-fetch (which retrieves the server-generated HeroImageEntryKey), there is nothing to reflect
// back here — HasWaveform is derived from WaveformEntryKey being non-null, which we know is now set.
row.HasWaveform = true;
Snackbar.Add($"Generated waveform for '{row.Release.Title}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for release {ReleaseId}", row.Release.Id);
Snackbar.Add($"Waveform generation failed for '{row.Release.Title}' — please try again.", Severity.Error);
}
finally
{
row.IsGenerating = false;
StateHasChanged();
}
}
public sealed class MixRow
{
public required ReleaseDto Release { get; set; }
public bool HasWaveform { get; set; }
public bool IsGenerating { get; set; }
}
}
@@ -0,0 +1,146 @@
@page "/tracks/sessions"
@inherits CmsMediumBrowserBase<CmsSessionBrowser.SessionRow>
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ILogger<CmsSessionBrowser> Logger
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
/tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions
(§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied
as its medium-specific RowActions slot so that affordance survives the move off the thin table. When
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in both contexts. *@
@if (Embedded)
{
@GridContent
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/tracks/archive"
Class="mb-4">
Back to Release Archive
</MudButton>
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
@GridContent
</MudContainer>
}
@code {
/// <summary>
/// True when rendered as tab content inside the Release Archive; suppresses the standalone page
/// chrome (title, container, back button). False (default) renders the full routable page.
/// </summary>
[Parameter] public bool Embedded { get; set; }
protected override ReleaseMedium Medium => ReleaseMedium.Session;
protected override string MediumNoun => "sessions";
protected override SessionRow ToRow(ReleaseDto release) => new()
{
Release = release,
HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey
};
protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release;
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
// both branches above render the same markup without duplication. The hero upload is the Session's
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
// matching SessionRow's upload state.
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync">
<RowActions Context="release">
@{ var row = RowFor(release); }
@if (row is not null)
{
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
{
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
<MudFileUpload T="IBrowserFile"
Accept="image/*"
FilesChanged="@(file => UploadHeroAsync(row, file))"
Disabled="@row.IsUploading">
<ActivatorContent>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Image"
Disabled="@row.IsUploading">
@if (row.IsUploading)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Uploading…</span>
}
else
{
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
}
</MudButton>
</ActivatorContent>
</MudFileUpload>
}
</RowActions>
</CmsAlbumBrowser>;
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
{
if (file is null) return;
row.IsUploading = true;
StateHasChanged();
try
{
await using var stream = file.OpenReadStream(maxAllowedSize: 50_000_000);
var result = await CmsReleaseService.UploadSessionHeroImageAsync(
row.Release.Id, stream, file.Name, file.ContentType);
if (result.Success)
{
// The endpoint returns no payload; the entry key is server-generated. Re-fetch the
// release so the hero thumbnail reflects the new key without guessing it.
var refreshed = await CmsReleaseService.GetByIdAsync(row.Release.Id);
if (refreshed.Success && refreshed.Value is { } release)
{
row.Release = release;
row.HeroImageEntryKey = release.SessionMetadata?.HeroImageEntryKey;
}
Snackbar.Add($"Hero image set for '{row.Release.Title}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Hero image upload failed: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Hero image upload failed for release {ReleaseId}", row.Release.Id);
Snackbar.Add("Hero image upload failed — please try again.", Severity.Error);
}
finally
{
row.IsUploading = false;
StateHasChanged();
}
}
public sealed class SessionRow
{
public required ReleaseDto Release { get; set; }
public string? HeroImageEntryKey { get; set; }
public bool IsUploading { get; set; }
}
}
@@ -0,0 +1,16 @@
/* Hero-thumbnail idiom for the session row's action cell. The hero <div> is authored in this
component's RowActions fragment, so Blazor stamps it with this component's scope attribute even
though CmsAlbumBrowser renders it this scoped copy reaches it (CSS isolation follows authoring
component, not rendering host). The grid's own cover thumb lives in CmsAlbumBrowser's scoped CSS. */
.cms-album-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.cms-album-thumb--fallback {
background-color: var(--mud-palette-action-default-hover);
}
@@ -0,0 +1,22 @@
@using DeepDrftModels.Enums
@* Cut-medium fields: the commercial release format. Plain explicit markup — no generics. *@
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseType"
Value="ReleaseType"
ValueChanged="@(v => ReleaseTypeChanged.InvokeAsync(v))"
Label="Release Type"
Variant="Variant.Outlined"
Disabled="Disabled">
@foreach (var rt in Enum.GetValues<ReleaseType>())
{
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
}
</MudSelect>
</MudItem>
@code {
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
}
@@ -0,0 +1,58 @@
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@* The single dispatch point for medium-conditional form fields. All five upload/edit forms embed this
one component; the @switch below is the ONLY place medium-specific form shape is decided. Adding a
medium is one new section component + one new switch arm here — nowhere else. *@
<MudGrid>
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseMedium"
Value="Medium"
ValueChanged="@(v => MediumChanged.InvokeAsync(v))"
Label="Medium"
Variant="Variant.Outlined"
Disabled="Disabled">
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudSelectItem T="ReleaseMedium" Value="medium">@medium</MudSelectItem>
}
</MudSelect>
</MudItem>
@switch (Medium)
{
case ReleaseMedium.Cut:
<CutFields ReleaseType="ReleaseType"
ReleaseTypeChanged="ReleaseTypeChanged"
Disabled="Disabled" />
break;
case ReleaseMedium.Session:
<SessionFields HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
break;
case ReleaseMedium.Mix:
<MixFields />
break;
}
</MudGrid>
@code {
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
// Cut-only — bound through to CutFields. Ignored for Session/Mix.
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
// Session-only — the held hero-image file, uploaded after create. Ignored for Cut/Mix.
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// Gates the hero file picker in SessionFields. True on the BatchUpload create path;
// false/absent on all edit paths so SessionFields falls back to the guidance alert.
[Parameter] public bool AllowHeroUpload { get; set; }
[Parameter] public bool Disabled { get; set; }
}
@@ -0,0 +1,10 @@
@* Mix-medium fields. The high-res waveform is a server-side derived datum: the CMS fires a body-less
trigger (POST api/release/{id}/mix/waveform) after the release exists, so generation is managed
per-row in the Mixes browser, not at create time. On upload the trigger is fired automatically; this
section states that contract and carries no input of its own. *@
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Mixes are single-track DJ releases. The high-resolution waveform is generated automatically
after upload; regenerate it any time from the <strong>Release Archive → Mixes</strong> browser.
</MudAlert>
</MudItem>
@@ -0,0 +1,68 @@
@using Microsoft.AspNetCore.Components.Forms
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
file picker is shown — the file is held by the parent and POSTed after the release is created.
When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
a dead/inert control on edit forms that do not wire the hero callbacks. *@
@if (AllowHeroUpload)
{
<MudItem xs="12">
<MudField Label="Hero Image" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
<MudText Typo="Typo.body2" Color="Color.Default">
Sessions are single-track live releases. The hero image is the session's primary visual identity.
</MudText>
@if (HeroImageFile is { } selectedHero)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedHero.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="Disabled"
OnClick="ClearHeroFile"
aria-label="Cancel hero image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No hero image — optional, but recommended.</MudText>
}
<InputFile OnChange="HandleHeroFileSelected" accept="image/*" disabled="@Disabled" />
@if (HeroImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
}
</MudStack>
</MudField>
</MudItem>
}
else
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Sessions are single-track live releases. After upload, set the hero image from the
<strong>Release Archive → Sessions</strong> browser.
</MudAlert>
</MudItem>
}
@code {
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
// When true (BatchUpload create path), render the hero file picker.
// When false/absent (edit paths), render the guidance alert directing the admin to the
// Sessions browser — no dead control where callbacks are unwired.
[Parameter] public bool AllowHeroUpload { get; set; }
private Task HandleHeroFileSelected(InputFileChangeEventArgs e) =>
HeroImageFileChanged.InvokeAsync(e.File);
private Task ClearHeroFile() =>
HeroImageFileChanged.InvokeAsync(null);
}
@@ -63,14 +63,7 @@
Label="Genre" Label="Genre"
Variant="Variant.Outlined" /> Variant="Variant.Outlined" />
<MudSelect @bind-Value="_form.ReleaseType" <MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
Label="Release Type"
Variant="Variant.Outlined">
@foreach (var releaseType in Enum.GetValues<ReleaseType>())
{
<MudSelectItem Value="releaseType">@releaseType</MudSelectItem>
}
</MudSelect>
<MudNumericField @bind-Value="_form.TrackNumber" <MudNumericField @bind-Value="_form.TrackNumber"
Label="Track Number" Label="Track Number"
@@ -217,6 +210,7 @@
releaseDate, releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath, string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
_form.ReleaseType, _form.ReleaseType,
_form.Medium,
_form.TrackNumber); _form.TrackNumber);
if (updated.Success) if (updated.Success)
{ {
@@ -304,6 +298,10 @@
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public int TrackNumber { get; set; } = 1; public int TrackNumber { get; set; } = 1;
public static TrackEditForm From(TrackDto track) => new() public static TrackEditForm From(TrackDto track) => new()
@@ -317,6 +315,7 @@
? d.ToDateTime(TimeOnly.MinValue) ? d.ToDateTime(TimeOnly.MinValue)
: null, : null,
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single, ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
TrackNumber = track.TrackNumber TrackNumber = track.TrackNumber
}; };
} }
@@ -1,7 +1,9 @@
@page "/tracks" @page "/tracks"
@page "/tracks/albums" @page "/tracks/albums"
@page "/tracks/genres" @page "/tracks/genres"
@page "/tracks/archive"
@using DeepDrftManager.Services @using DeepDrftManager.Services
@using DeepDrftModels.Enums
@inject CmsTrackBrowserViewModel VM @inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -35,6 +37,9 @@
} }
</MudStack> </MudStack>
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
<MudToggleGroup T="BrowseMode" <MudToggleGroup T="BrowseMode"
Value="VM.Mode" Value="VM.Mode"
ValueChanged="OnModeChanged" ValueChanged="OnModeChanged"
@@ -44,7 +49,6 @@
Class="mb-4"> Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem> <MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem> <MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
</MudToggleGroup> </MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks) @if (VM.Mode == BrowseMode.Tracks)
@@ -53,12 +57,40 @@
} }
else if (VM.Mode == BrowseMode.Albums) else if (VM.Mode == BrowseMode.Albums)
{ {
<CmsAlbumBrowser Releases="VM.Albums" @* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
IsLoading="VM.AlbumsLoading" The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
OnReleasesChanged="OnAlbumsChanged" /> entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
tab swaps the grid below in place; no navigation to a separate page occurs. *@
@* Medium-aware Add Track (§8.E): the button lives in the tab-strip chrome (not inside any grid
component — 8.C owns those) and reflects the active tab. It pre-selects the upload form to the
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a
seed only — the upload form's selector stays user-changeable after landing. *@
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="@AddTrackHref(ActiveMedium)">
Add Track
</MudButton>
</MudStack>
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
@bind-ActivePanelIndex="_activeTabIndex">
<MudTabPanel Text="ALL">
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
</MudTabPanel>
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
<MudTabPanel Text="@MediumTabLabels[medium]">
@MediumGrid(medium)
</MudTabPanel>
}
</MudTabs>
} }
else else
{ {
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
Reachable by direct URL; no longer in the toggle group. *@
<CmsGenreBrowser Genres="VM.Genres" <CmsGenreBrowser Genres="VM.Genres"
IsLoading="VM.GenresLoading" IsLoading="VM.GenresLoading"
ExpandedGenre="@VM.ExpandedGenre" ExpandedGenre="@VM.ExpandedGenre"
@@ -69,8 +101,46 @@
@code { @code {
private CmsTrackGrid? _grid; private CmsTrackGrid? _grid;
// The album browser owns its own row state and removes a deleted release locally. Invalidate the // Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in
// VM cache so genres and album counts reflect the deletion on next mode switch. // order. Drives the medium-aware Add Track button (§8.E).
private int _activeTabIndex;
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut
// (Daniel, 2026-06-13); each medium tab maps to its enum value by position, so a fourth medium tab
// gets a correct Add Track for free — no markup fork.
private ReleaseMedium ActiveMedium =>
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
private static string AddTrackHref(ReleaseMedium medium) =>
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
new Dictionary<ReleaseMedium, string>
{
[ReleaseMedium.Cut] = "CUTS",
[ReleaseMedium.Session] = "SESSIONS",
[ReleaseMedium.Mix] = "MIXES",
};
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
{
ReleaseMedium.Cut => @<CmsCutBrowser />,
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
};
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
private void OnAlbumsChanged() private void OnAlbumsChanged()
{ {
VM.Invalidate(); VM.Invalidate();
@@ -84,12 +154,14 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
var uri = NavigationManager.Uri; var uri = NavigationManager.Uri;
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) var initial =
? BrowseMode.Albums uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) : uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
? BrowseMode.Genres : uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
: BrowseMode.Tracks; : BrowseMode.Tracks;
await VM.SwitchModeAsync(initial); await VM.SwitchModeAsync(initial);
} }
@@ -5,6 +5,7 @@
@attribute [Authorize] @attribute [Authorize]
@inject ICmsTrackService CmsTrackService @inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@@ -32,6 +33,8 @@
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" /> <MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" /> <MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false"> <MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3"> <MudStack Spacing="3">
@if (_selectedImageFile is { } selectedImage) @if (_selectedImageFile is { } selectedImage)
@@ -104,6 +107,8 @@
private string _album = string.Empty; private string _album = string.Empty;
private string _genre = string.Empty; private string _genre = string.Empty;
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
private string? _errorMessage; private string? _errorMessage;
private bool _isUploading; private bool _isUploading;
@@ -205,11 +210,24 @@
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate, string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
_selectedFile.Name, _selectedFile.Name,
createdByUserId, createdByUserId,
releaseType: ReleaseType.Single, _releaseType,
trackNumber: 1); trackNumber: 1,
_medium);
if (result.Success) if (result.Success)
{ {
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
// The upload endpoint does not accept an imagePath, so link the cover art with a // The upload endpoint does not accept an imagePath, so link the cover art with a
// follow-up metadata update — same two-step pattern TrackEdit uses. // follow-up metadata update — same two-step pattern TrackEdit uses.
if (_imagePath is { } imgPath && result.Value is { } created) if (_imagePath is { } imgPath && result.Value is { } created)
+4
View File
@@ -23,6 +23,10 @@ builder.Services.AddMudServices();
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer. // DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>(); builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
// CMS release operations (medium-filtered browse + Session/Mix media ops) over HTTP to the
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets). // Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
builder.Services.AddScoped<CmsTrackBrowserViewModel>(); builder.Services.AddScoped<CmsTrackBrowserViewModel>();
@@ -0,0 +1,222 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftManager.Services;
/// <summary>
/// HTTP client over DeepDrftAPI's <c>api/release</c> family for CMS release operations. Mirrors
/// <see cref="CmsTrackService"/>: the Manager is InteractiveServer-only with no in-process data
/// layer, so every read and write is a network call. The ApiKey is baked into the
/// <c>DeepDrft.Content.Cms</c> named client's default headers; the unauthenticated reads still go
/// through it (the extra header is harmless on public endpoints).
/// </summary>
public class CmsReleaseService : ICmsReleaseService
{
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CmsReleaseService> _logger;
public CmsReleaseService(
IHttpClientFactory httpClientFactory,
ILogger<CmsReleaseService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
ReleaseMedium? medium,
int page, int pageSize, string? sortColumn, bool sortDescending,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (medium is { } m)
{
query += $"&medium={Uri.EscapeDataString(m.ToString())}";
}
if (!string.IsNullOrWhiteSpace(sortColumn))
{
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
}
HttpResponseMessage response;
try
{
response = await client.GetAsync(query, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release page (medium {Medium})", medium);
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release page failed: {Status}", (int)response.StatusCode);
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Failed to load releases.");
}
PagedResult<ReleaseDto>? paged;
try
{
paged = await response.Content.ReadFromJsonAsync<PagedResult<ReleaseDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize release page from Content API response");
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
}
if (paged is null)
{
_logger.LogError("Content API returned a null release page");
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(paged);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync($"api/release/{id}", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release {ReleaseId}", id);
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release lookup failed for {ReleaseId}: {Status}", id, (int)response.StatusCode);
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to load release.");
}
ReleaseDto? release;
try
{
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize ReleaseDto from Content API response");
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
}
}
public async Task<Result> UploadSessionHeroImageAsync(
long releaseId,
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default)
{
using var multipart = new MultipartFormDataContent();
var imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
// Field name "image" matches the controller's [FromForm] IFormFile image parameter.
multipart.Add(imageContent, "image", fileName);
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, $"api/release/{releaseId}/session/hero-image")
{
Content = multipart
};
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for hero-image upload of release {ReleaseId}", releaseId);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Release not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
var statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
_logger.LogError("Content API returned {Status} for hero-image upload of release {ReleaseId}: {Body}", statusCode, releaseId, body);
return Result.CreateFailResult("Hero image upload failed on the content server.");
}
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
_logger.LogWarning("Content API rejected hero-image upload for release {ReleaseId}: {Status} {Body}", releaseId, statusCode, body);
return Result.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Hero image upload rejected ({statusCode})." : body);
}
}
public async Task<Result> GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/release/{releaseId}/mix/waveform", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for mix waveform generation of release {ReleaseId}", releaseId);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Mix audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API mix waveform generation failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate mix waveform.");
}
}
}
@@ -2,11 +2,14 @@ using DeepDrftModels.DTOs;
namespace DeepDrftManager.Services; namespace DeepDrftManager.Services;
/// <summary>The three browse dimensions for the /tracks page.</summary> /// <summary>The browse dimensions for the /tracks page.</summary>
public enum BrowseMode public enum BrowseMode
{ {
Tracks, Tracks,
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
Albums, Albums,
Genres, Genres,
} }
@@ -27,34 +30,23 @@ public class CmsTrackBrowserViewModel
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks; public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
// Album mode.
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
public bool AlbumsLoading { get; private set; }
// Genre mode. // Genre mode.
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>(); public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
public bool GenresLoading { get; private set; } public bool GenresLoading { get; private set; }
public string? ExpandedGenre { get; private set; } public string? ExpandedGenre { get; private set; }
/// <summary> /// <summary>
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses /// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there. /// collapsing any expanded genre row. Track mode and the all-releases grid (Albums mode) each own
/// their own data — the grid loads itself (see <c>CmsAllReleasesGrid</c>) — so no fetch happens for
/// either here.
/// </summary> /// </summary>
public async Task SwitchModeAsync(BrowseMode mode) public async Task SwitchModeAsync(BrowseMode mode)
{ {
Mode = mode; Mode = mode;
ExpandedGenre = null; // collapse on mode switch ExpandedGenre = null; // collapse on mode switch
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading) if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{
AlbumsLoading = true;
var result = await _trackService.GetReleasesAsync();
Albums = result.Success && result.Value is not null
? result.Value
: Array.Empty<ReleaseDto>();
AlbumsLoading = false;
}
else if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{ {
GenresLoading = true; GenresLoading = true;
var result = await _trackService.GetGenreSummariesAsync(); var result = await _trackService.GetGenreSummariesAsync();
@@ -72,13 +64,13 @@ public class CmsTrackBrowserViewModel
} }
/// <summary> /// <summary>
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into /// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete) /// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
/// since both datasets are derived from the catalogue and go stale on any such change. /// summaries are derived from the catalogue and go stale on any such change. The all-releases grid
/// owns and refreshes its own data, so it needs no invalidation here.
/// </summary> /// </summary>
public void Invalidate() public void Invalidate()
{ {
Albums = Array.Empty<ReleaseDto>();
Genres = Array.Empty<GenreSummaryDto>(); Genres = Array.Empty<GenreSummaryDto>();
} }
} }
@@ -44,6 +44,7 @@ public class CmsTrackService : ICmsTrackService
long createdByUserId, long createdByUserId,
ReleaseType releaseType, ReleaseType releaseType,
int trackNumber, int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// Rebuild the multipart container so the boundary is owned by HttpClient and the // Rebuild the multipart container so the boundary is owned by HttpClient and the
@@ -63,6 +64,9 @@ public class CmsTrackService : ICmsTrackService
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId"); multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
multipart.Add(new StringContent(releaseType.ToString()), "releaseType"); multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber"); multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
var client = _httpClientFactory.CreateClient(ContentCmsClientName); var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart }; using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
@@ -370,6 +374,7 @@ public class CmsTrackService : ICmsTrackService
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null, string? imagePath = null,
ReleaseType? releaseType = null, ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
int? trackNumber = null, int? trackNumber = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@@ -383,6 +388,7 @@ public class CmsTrackService : ICmsTrackService
releaseDate, releaseDate,
imagePath, imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null, releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
medium = medium.HasValue ? (int?)medium.Value : null,
trackNumber, trackNumber,
}; };
@@ -0,0 +1,51 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftManager.Services;
/// <summary>
/// CMS-side release operations for the Manager host. Mirrors <see cref="ICmsTrackService"/>: every
/// read and write goes over HTTP to DeepDrftAPI's <c>api/release</c> family, which is the single
/// authority over both the SQL metadata store and the binary vault. The Manager holds no in-process
/// data layer.
/// </summary>
public interface ICmsReleaseService
{
/// <summary>
/// Fetch a page of releases from <c>GET api/release</c>, optionally filtered to one
/// <paramref name="medium"/>. The matching medium's metadata satellite is populated on each row;
/// the others are null. Null medium returns all releases unfiltered.
/// </summary>
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
ReleaseMedium? medium,
int page, int pageSize, string? sortColumn, bool sortDescending,
CancellationToken ct = default);
/// <summary>
/// Fetch a single release with both metadata navs from <c>GET api/release/{id}</c> (nulls for the
/// non-matching medium). A 404 returns a passing result with a null value.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Upload a Session hero image via <c>POST api/release/{id}/session/hero-image</c> (multipart).
/// The server stores it in the image vault and sets <c>SessionMetadata.HeroImageEntryKey</c>.
/// Maps a 404 to a "Release not found." failure; relays 4xx validation text as-is.
/// </summary>
Task<Result> UploadSessionHeroImageAsync(
long releaseId,
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Trigger high-resolution waveform generation for a Mix via
/// <c>POST api/release/{id}/mix/waveform</c> (no body). The server fetches the mix audio from its
/// own vault, computes the datum, stores it, and sets <c>MixMetadata.WaveformEntryKey</c>. Maps a
/// 404 to a "Mix audio not found." failure.
/// </summary>
Task<Result> GenerateMixWaveformAsync(long releaseId, CancellationToken ct = default);
}
+8 -1
View File
@@ -18,6 +18,9 @@ public interface ICmsTrackService
/// orphan is handled and logged server-side; here it surfaces as a failed result. /// orphan is handled and logged server-side; here it surfaces as a failed result.
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and /// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
/// stored as metadata; it is not user-editable afterwards. /// stored as metadata; it is not user-editable afterwards.
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/> when this upload
/// creates the release. The medium is authoritative only on creation — adding a track to an existing
/// release never changes its medium (that is the edit path, <see cref="UpdateAsync"/>).
/// </summary> /// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync( Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream, Stream wavStream,
@@ -32,6 +35,7 @@ public interface ICmsTrackService
long createdByUserId, long createdByUserId,
ReleaseType releaseType, ReleaseType releaseType,
int trackNumber, int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary> /// <summary>
@@ -75,13 +79,16 @@ public interface ICmsTrackService
/// <summary> /// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and /// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the /// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
/// cover art unchanged, <c>""</c> clears it, and any other value sets it. /// cover art unchanged, <c>""</c> clears it, and any other value sets it. <paramref name="medium"/>
/// is null = no change; a non-null, non-Cut value resets the release's ReleaseType to its default
/// server-side, since ReleaseType is meaningful only for Cut.
/// </summary> /// </summary>
Task<Result> UpdateAsync( Task<Result> UpdateAsync(
long id, string trackName, string artist, long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate, string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null, string? imagePath = null,
ReleaseType? releaseType = null, ReleaseType? releaseType = null,
ReleaseMedium? medium = null,
int? trackNumber = null, int? trackNumber = null,
CancellationToken ct = default); CancellationToken ct = default);
+10
View File
@@ -0,0 +1,10 @@
namespace DeepDrftModels.DTOs;
// Mirror of MixMetadata (Phase 9). No `required` members — BlazorBlocks's Manager<> generic
// constraint requires `new()`, which does not compose with required members. TrackConverter assigns
// every field on the round-trip, so an empty default is never observable.
public class MixMetadataDto
{
public long ReleaseId { get; set; }
public string WaveformEntryKey { get; set; } = string.Empty;
}
+10 -1
View File
@@ -14,7 +14,16 @@ public class ReleaseDto : BaseModel
public string? Genre { get; set; } public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
// Nullable: meaningful only for Cut releases. TrackConverter nulls it for Session/Mix at the
// mapping point. One producer enforces the rule; no consumer depends on a non-null value.
public ReleaseType? ReleaseType { get; set; }
// Medium-specific satellites. Populated only for the matching medium; null otherwise.
public SessionMetadataDto? SessionMetadata { get; set; }
public MixMetadataDto? MixMetadata { get; set; }
public long? CreatedByUserId { get; set; } public long? CreatedByUserId { get; set; }
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the // Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
+25
View File
@@ -0,0 +1,25 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Cross-project release filter contract for the paged release read surface. Threaded alongside
/// (never inside) the external <c>PagingParameters&lt;T&gt;</c>, which cannot carry a where-clause,
/// and beside the medium filter (a separate enum param, not a free-text field). Mirrors
/// <see cref="TrackFilter"/> for the release-cardinal browse path. An instance with all properties
/// null is equivalent to no filter — see <see cref="IsEmpty"/>.
/// </summary>
public class ReleaseFilter
{
/// <summary>Free-text, case-insensitive LIKE across the release Title and Artist.</summary>
public string? SearchText { get; set; }
/// <summary>Exact genre match.</summary>
public string? Genre { get; set; }
/// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying.
/// </summary>
public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Genre);
}
+10
View File
@@ -0,0 +1,10 @@
namespace DeepDrftModels.DTOs;
// Mirror of SessionMetadata (Phase 9). No `required` members — BlazorBlocks's Manager<> generic
// constraint requires `new()`, which does not compose with required members. TrackConverter assigns
// every field on the round-trip, so an empty default is never observable.
public class SessionMetadataDto
{
public long ReleaseId { get; set; }
public string HeroImageEntryKey { get; set; } = string.Empty;
}
+8 -1
View File
@@ -16,6 +16,12 @@ public class TrackFilter
/// <summary>Exact genre match.</summary> /// <summary>Exact genre match.</summary>
public string? Genre { get; set; } public string? Genre { get; set; }
/// <summary>
/// Exact release-id match. The authoritative join from a release to its tracks — preferred over
/// <see cref="Album"/> (a title string that collides across same-titled releases and breaks on rename).
/// </summary>
public long? ReleaseId { get; set; }
/// <summary> /// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null /// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying. /// filter, so callers collapse it to null before querying.
@@ -23,5 +29,6 @@ public class TrackFilter
public bool IsEmpty => public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText) string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Album) && string.IsNullOrWhiteSpace(Album)
&& string.IsNullOrWhiteSpace(Genre); && string.IsNullOrWhiteSpace(Genre)
&& ReleaseId is null;
} }
+17
View File
@@ -0,0 +1,17 @@
using Models.Entities;
namespace DeepDrftModels.Entities;
// 1:1 satellite for Mix-medium releases (Phase 9). One row per Mix ReleaseEntity, keyed by a unique
// ReleaseId FK (the 1:1 enforcement lives in MixMetadataConfiguration). Carries the entry key for
// the preprocessed high-resolution waveform datum.
//
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
public class MixMetadata : BaseEntity, IEntity
{
public long ReleaseId { get; set; }
public ReleaseEntity Release { get; set; } = null!;
public required string WaveformEntryKey { get; set; }
}
+7
View File
@@ -18,6 +18,13 @@ public class ReleaseEntity : BaseEntity, IEntity
public DateOnly? ReleaseDate { get; set; } public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; } public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single; public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
public long? CreatedByUserId { get; set; } public long? CreatedByUserId { get; set; }
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>(); public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
// 1:1 satellites selected by Medium. Null unless this release is the matching medium —
// Session releases carry SessionMetadata, Mix releases carry MixMetadata, Cut releases carry
// neither (ReleaseType on this table is their discriminator data).
public SessionMetadata? SessionMetadata { get; set; }
public MixMetadata? MixMetadata { get; set; }
} }
@@ -0,0 +1,17 @@
using Models.Entities;
namespace DeepDrftModels.Entities;
// 1:1 satellite for Session-medium releases (Phase 9). One row per Session ReleaseEntity, keyed by
// a unique ReleaseId FK (the 1:1 enforcement lives in SessionMetadataConfiguration). Carries the
// hero-image entry key into the Image vault.
//
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
public class SessionMetadata : BaseEntity, IEntity
{
public long ReleaseId { get; set; }
public ReleaseEntity Release { get; set; } = null!;
public required string HeroImageEntryKey { get; set; }
}
+35
View File
@@ -0,0 +1,35 @@
namespace DeepDrftModels.Enums;
/// <summary>
/// The allowed track-count range for a <see cref="ReleaseMedium"/>, expressed as an inclusive
/// [Min, Max] band. <c>Max == int.MaxValue</c> denotes an unbounded (many-track) medium.
/// </summary>
public readonly record struct MediumCardinality(int Min, int Max)
{
/// <summary>True when <paramref name="trackCount"/> falls within the inclusive band.</summary>
public bool Allows(int trackCount) => trackCount >= Min && trackCount <= Max;
/// <summary>True when the medium permits exactly one track (Max capped at 1).</summary>
public bool IsSingleTrack => Max == 1;
}
/// <summary>
/// Single source of truth for per-medium structural rules. Today it declares track cardinality;
/// the same table is read by the upload service (to reject over-limit track-adds) and the CMS
/// batch forms (to decide whether to collapse the master list to one row), so the two cannot
/// drift. A future medium declares its cardinality here — and only here — and every consumer
/// honours it automatically. Pure declaration, no dependencies.
/// </summary>
public static class MediumRules
{
private static readonly IReadOnlyDictionary<ReleaseMedium, MediumCardinality> Cardinalities =
new Dictionary<ReleaseMedium, MediumCardinality>
{
[ReleaseMedium.Cut] = new(1, int.MaxValue),
[ReleaseMedium.Session] = new(1, 1),
[ReleaseMedium.Mix] = new(1, 1),
};
/// <summary>The declared track-count band for <paramref name="medium"/>.</summary>
public static MediumCardinality CardinalityOf(ReleaseMedium medium) => Cardinalities[medium];
}
+16
View File
@@ -0,0 +1,16 @@
namespace DeepDrftModels.Enums;
/// <summary>
/// The medium of a release — the Phase 9 discriminator that selects which metadata shape applies.
/// </summary>
public enum ReleaseMedium
{
/// <summary>Studio recording. Uses <see cref="ReleaseType"/> (Single/EP/Album). The default.</summary>
Cut,
/// <summary>Single live track plus a hero image. Detail in <c>SessionMetadata</c>.</summary>
Session,
/// <summary>Single long track plus a preprocessed high-resolution waveform datum. Detail in <c>MixMetadata</c>.</summary>
Mix
}
@@ -0,0 +1,109 @@
using DeepDrftModels.DTOs;
using Models.Common;
using NetBlocks.Models;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace DeepDrftPublic.Client.Clients;
/// <summary>
/// HTTP client for the release read surface (Phase 9). Uses the named <c>"DeepDrft.API"</c>
/// client like <see cref="TrackClient"/>: on WASM it points at the public host and proxies
/// through <c>ReleaseProxyController</c>; on SSR prerender it points directly at DeepDrftAPI.
/// All routes are unauthenticated reads. Responses deserialize as bare DTOs (no ApiResultDto
/// envelope), matching the API's <c>Ok(value)</c> shape.
/// </summary>
public class ReleaseClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
public ReleaseClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.API");
}
public async Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false,
string? search = null,
string? genre = null)
{
var queryArgs = new Dictionary<string, string?>
{
["page"] = page.ToString(),
["pageSize"] = pageSize.ToString()
};
if (!string.IsNullOrEmpty(medium))
queryArgs["medium"] = medium;
if (!string.IsNullOrEmpty(search))
queryArgs["q"] = search;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
if (!string.IsNullOrEmpty(sortColumn))
queryArgs["sortColumn"] = sortColumn;
if (sortDescending)
queryArgs["sortDescending"] = "true";
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/release{query}");
if (!response.IsSuccessStatusCode)
return ApiResult<PagedResult<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var paged = JsonSerializer.Deserialize<PagedResult<ReleaseDto>>(json, JsonOptions);
return paged is not null
? ApiResult<PagedResult<ReleaseDto>>.CreatePassResult(paged)
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<ReleaseDto>> GetById(long id)
{
var response = await _http.GetAsync($"api/release/{id}");
if (!response.IsSuccessStatusCode)
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var release = JsonSerializer.Deserialize<ReleaseDto>(json, JsonOptions);
return release is not null
? ApiResult<ReleaseDto>.CreatePassResult(release)
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored
/// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a
/// null value. Any other non-success status is a genuine failure.
/// </summary>
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
{
var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
return profile is not null
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
}
}
+5 -1
View File
@@ -23,7 +23,8 @@ public class TrackClient
bool sortDescending = false, bool sortDescending = false,
string? searchText = null, string? searchText = null,
string? album = null, string? album = null,
string? genre = null) string? genre = null,
long? releaseId = null)
{ {
var queryArgs = new Dictionary<string, string?>(){ var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(), ["page"] = pageNumber.ToString(),
@@ -45,6 +46,9 @@ public class TrackClient
if (!string.IsNullOrEmpty(genre)) if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre; queryArgs["genre"] = genre;
if (releaseId is { } id)
queryArgs["releaseId"] = id.ToString();
string query = QueryString.Create(queryArgs).ToString(); string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}"); var response = await _http.GetAsync($"api/track/page{query}");
@@ -0,0 +1,35 @@
@namespace DeepDrftPublic.Client.Controls
@* Full-page background waveform for a Mix release. Deliberately NOT the player-bar peak-bar idiom
(SpectrumVisualizer / LevelMeterFab own that): this renders a single continuous mirrored
silhouette filling the viewport behind the detail content. Fetches its own datum from
api/release/{id}/mix/waveform. The played portion is washed with the progress overlay driven by
PlaybackPosition; the click-to-seek seam (OnSeek + bindable PlaybackPosition) is wired here for a
future wave even though click handling does not ship yet. *@
<div class="mix-waveform-bg @(_profile is null ? "mix-waveform-bg--empty" : null)">
@if (_profile is not null)
{
<svg class="mix-waveform-svg"
viewBox="0 0 @ViewBoxWidth 100"
preserveAspectRatio="none"
role="img"
aria-label="Waveform">
<defs>
<clipPath id="@_clipId">
<path d="@_silhouettePath" />
</clipPath>
</defs>
@* Base silhouette. *@
<path d="@_silhouettePath" class="mix-waveform-fill" />
@* Played-portion wash: a full-height rect clipped to the silhouette, width tracking
PlaybackPosition. Clamped to [0, 1]. *@
<rect x="0" y="0"
width="@(ClampedPosition * ViewBoxWidth)" height="100"
class="mix-waveform-played"
clip-path="url(#@_clipId)" />
</svg>
}
</div>
@@ -0,0 +1,132 @@
using System.Globalization;
using System.Text;
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Renders a Mix release's stored loudness profile as a full-page background silhouette. Standalone
/// and reusable: give it a <see cref="ReleaseId"/> and it fetches its own datum. Visually distinct
/// from the player-bar spectrum/level idiom by design — this is a single continuous mirrored wave,
/// not discrete peak bars.
/// </summary>
public partial class MixWaveformVisualizer : ComponentBase
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }
/// <summary>The Mix release whose waveform datum to fetch and render.</summary>
[Parameter] public required long ReleaseId { get; set; }
/// <summary>
/// Normalized playback head in [0, 1]. Two-way bindable so a future click-to-seek can write back
/// through it; today it is read-only input that drives the played-portion wash. The seam exists
/// now so wiring click-to-seek later is a pure addition, not a signature change.
/// </summary>
[Parameter] public double PlaybackPosition { get; set; }
[Parameter] public EventCallback<double> PlaybackPositionChanged { get; set; }
/// <summary>
/// Fired when the user seeks by interacting with the waveform. Unused until click-to-seek ships;
/// present now to lock the seek seam into the public contract.
/// </summary>
[Parameter] public EventCallback<double> OnSeek { get; set; }
// Fixed SVG coordinate width. The path is computed in this space, then stretched to the
// viewport via preserveAspectRatio="none".
private const int ViewBoxWidth = 1000;
private readonly string _clipId = $"mix-wf-clip-{Guid.NewGuid():N}";
private WaveformProfileDto? _profile;
private string _silhouettePath = string.Empty;
private long? _loadedReleaseId;
private double ClampedPosition => Math.Clamp(PlaybackPosition, 0d, 1d);
protected override async Task OnParametersSetAsync()
{
// ReleaseId is the only fetch input; fetch once per id. A PlaybackPosition update re-renders
// but must not refetch — and a release with no datum must not refetch either, so the guard
// keys on the fetched id, not on whether a profile came back.
if (_loadedReleaseId == ReleaseId)
return;
_loadedReleaseId = ReleaseId;
var result = await ReleaseData.GetMixWaveform(ReleaseId);
if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0)
{
_profile = profile;
try
{
_silhouettePath = BuildSilhouettePath(profile);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "MixWaveformVisualizer: failed to decode waveform profile for release {ReleaseId}; rendering empty backdrop.", ReleaseId);
_profile = null;
_silhouettePath = string.Empty;
}
}
else
{
// No datum (not generated yet, or not a Mix) — leave the background empty; the detail
// page still renders its content over a plain backdrop.
_profile = null;
_silhouettePath = string.Empty;
}
}
// Builds a closed, vertically mirrored silhouette path across the buckets. Loudness bytes are
// [0, 255]; mapped to a half-height amplitude around the vertical midline (y=50). The top edge
// runs left-to-right, the bottom edge mirrors right-to-left, and the path closes — yielding a
// filled continuous wave shape rather than separate bars.
private static string BuildSilhouettePath(WaveformProfileDto profile)
{
var data = Convert.FromBase64String(profile.Data);
int n = data.Length;
if (n == 0) return string.Empty;
const double midline = 50d;
const double maxAmplitude = 48d; // leave a 2-unit margin top and bottom
double step = n > 1 ? (double)ViewBoxWidth / (n - 1) : ViewBoxWidth;
var sb = new StringBuilder();
// Top edge, left to right.
for (int i = 0; i < n; i++)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline - amp;
sb.Append(i == 0 ? 'M' : 'L');
AppendPoint(sb, x, y);
}
// Bottom edge, right to left (mirror).
for (int i = n - 1; i >= 0; i--)
{
double x = i * step;
double amp = data[i] / 255d * maxAmplitude;
double y = midline + amp;
sb.Append('L');
AppendPoint(sb, x, y);
}
sb.Append('Z');
return sb.ToString();
}
private static void AppendPoint(StringBuilder sb, double x, double y)
{
sb.Append(x.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
sb.Append(y.ToString("0.##", CultureInfo.InvariantCulture));
sb.Append(' ');
}
}
@@ -0,0 +1,31 @@
/* Full-viewport fixed backdrop. Sits behind page content (negative-ish z-index within the
detail layout) and never intercepts pointer events until click-to-seek ships. */
.mix-waveform-bg {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
display: flex;
align-items: center;
}
.mix-waveform-bg--empty {
/* No datum: nothing to draw. Kept as a hook for a future flat-line fallback. */
}
.mix-waveform-svg {
width: 100%;
height: 60vh;
margin: auto 0;
opacity: 0.18;
}
/* Native SVG elements — scoped CSS stamps these directly, no ::deep needed. */
.mix-waveform-fill {
fill: var(--mud-palette-text-secondary);
}
.mix-waveform-played {
fill: var(--mud-palette-primary);
}
@@ -0,0 +1,40 @@
@namespace DeepDrftPublic.Client.Controls
@* Invariant trio shared by every medium's detail page: a back link, a masthead (title + artist),
a play/share affordance row wired to the streaming player, and slots for the medium-specific
hero visual and metadata block. TrackDetail and the Session/Mix detail pages all compose this;
per-medium variance rides the Hero and MetaContent render fragments. *@
<div class="deepdrft-track-detail-container">
<MudLink Href="@BackHref" Typo="Typo.body2" Class="deepdrft-track-detail-back">
&larr; @BackLabel
</MudLink>
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h3">@Title</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@Artist</MudText>
</div>
@* Play + share only make sense once a playable track is resolved. *@
@if (Track is not null)
{
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<SharePopover EntryKey="@Track.EntryKey" />
<PlayStateIcon Track="@Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
</MudStack>
}
</MudStack>
@Hero
@if (MetaContent is not null && ShowMeta)
{
<MudDivider />
<div class="deepdrft-track-detail-meta">
@MetaContent
</div>
}
</div>
@@ -0,0 +1,56 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls;
/// <summary>
/// Shared detail-page chrome for any release medium: back link, masthead, play/share affordance,
/// and hero/meta slots. Owns the play-toggle wiring against the cascaded streaming player so each
/// detail page supplies only its data and medium-specific visuals. Extracted from the original
/// TrackDetail page, which is now a thin consumer of this scaffold.
/// </summary>
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
// The playable track for this release. Null while unresolved (or when a release has no
// streamable track yet) — the play/share row is hidden in that case.
[Parameter] public TrackDto? Track { get; set; }
[Parameter] public string BackHref { get; set; } = "/archive";
[Parameter] public string BackLabel { get; set; } = "Archive";
/// <summary>Medium-specific hero visual (cover art, hero image, or waveform background).</summary>
[Parameter] public RenderFragment? Hero { get; set; }
/// <summary>Optional medium-specific metadata block, rendered under a divider when present.</summary>
[Parameter] public RenderFragment? MetaContent { get; set; }
/// <summary>
/// Gate for the metadata block. Lets a consumer supply a <see cref="MetaContent"/> fragment but
/// suppress the divider + block when its data is empty (slot fragments cannot be conditionally
/// attached inline). Defaults to shown.
/// </summary>
[Parameter] public bool ShowMeta { get; set; } = true;
private async Task PlayTrack()
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(Track);
}
}
}
@@ -0,0 +1,75 @@
@namespace DeepDrftPublic.Client.Controls
@* Card grid of releases that open their own detail page (/{DetailRoute}/{id}). Shared by the
Sessions and Mixes browse pages. Cuts intentionally do not use this — they open the track
gallery filtered by album, a different navigation target. Fully controlled by the parent:
loading and item state are passed in. *@
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="release-gallery-container">
@if (Loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (Releases.Count == 0)
{
<div class="release-gallery-empty">
<MudText Typo="Typo.h6">@EmptyMessage</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var release in Releases)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="release-card-center">
<a href="@($"/{DetailRoute}/{release.Id}")" class="release-card-link">
<div class="release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="release-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
</div>
}
else
{
<div class="release-card-cover release-card-cover--fallback"></div>
}
<div class="release-card-body">
<MudText Typo="Typo.subtitle1" Class="release-card-title text-truncate">
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="release-card-artist text-truncate">
@release.Artist
</MudText>
</div>
</div>
</a>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@code {
[Parameter] public required IReadOnlyList<DeepDrftModels.DTOs.ReleaseDto> Releases { get; set; }
[Parameter] public bool Loading { get; set; }
/// <summary>Route segment for a card's detail page; a card links to /{DetailRoute}/{id}.</summary>
[Parameter] public required string DetailRoute { get; set; }
[Parameter] public string EmptyMessage { get; set; } = "Nothing here yet";
}
@@ -0,0 +1,63 @@
.release-gallery-container {
padding-top: 16px;
}
.release-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.release-card-link {
text-decoration: none;
color: inherit;
}
.release-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.release-card:hover {
transform: translateY(-4px);
}
.release-card-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.release-card-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.release-card-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* release-card-title / release-card-artist ride on MudText (child Razor component); ::deep
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
::deep .release-card-title {
font-weight: 600;
}
::deep .release-card-artist {
opacity: 0.7;
}
.release-gallery-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
+17 -14
View File
@@ -10,21 +10,24 @@
<ul class="dd-nav-links"> <ul class="dd-nav-links">
@foreach (var navPage in Pages.MenuPages) @foreach (var navPage in Pages.MenuPages)
{ {
@* Above the medium breakpoint the medium modes ride inline beside their parent:
the parent link renders, then each child renders as its own sibling appbar link.
No popover — so the 8.J stuck-open dismissal applies only to the mobile drawer
below, where clicks close the menu. *@
<li> <li>
<a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a> <a href="@navPage.Route" class="dd-nav-link">@navPage.Name</a>
</li> </li>
@foreach (var child in navPage.Children)
{
<li>
<a href="@child.Route" class="dd-nav-link">@child.Name</a>
</li>
}
} }
</ul> </ul>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" /> <StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" />
@* <button type="button" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@* aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()" *@
@* @onclick="DarkModeToggle"> *@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
</div> </div>
</nav> </nav>
</div> </div>
@@ -35,13 +38,6 @@
<a class="dd-nav-brand" href="/">Deep DRFT</a> <a class="dd-nav-brand" href="/">Deep DRFT</a>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
@* <button type="button" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@* aria-pressed="@IsDarkMode.ToString().ToLowerInvariant()" *@
@* @onclick="DarkModeToggle"> *@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
<button type="button" <button type="button"
class="dd-nav-hamburger" class="dd-nav-hamburger"
aria-label="Toggle navigation" aria-label="Toggle navigation"
@@ -59,6 +55,13 @@
<li> <li>
<a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a> <a href="@navPage.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@navPage.Name</a>
</li> </li>
@* One-level fan-out: render children as an indented sub-list under the parent link. *@
@foreach (var child in navPage.Children)
{
<li class="dd-nav-mobile-child">
<a href="@child.Route" class="dd-nav-link" @onclick="CloseMobileMenu">@child.Name</a>
</li>
}
} }
<li> <li>
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" OnStreamStarted="CloseMobileMenu" /> <StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" OnStreamStarted="CloseMobileMenu" />
@@ -207,6 +207,11 @@
padding: 0.6rem 0; padding: 0.6rem 0;
} }
/* Indented child rows under their parent link in the mobile drawer. */
.dd-nav-mobile-child {
padding-left: 1.25rem;
}
.dd-nav-links-mobile ::deep .dd-nav-cta { .dd-nav-links-mobile ::deep .dd-nav-cta {
margin-top: 0.5rem; margin-top: 0.5rem;
text-align: center; text-align: center;
+24 -6
View File
@@ -7,20 +7,38 @@ public class PageRoute
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string Route { get; set; } = string.Empty; public string Route { get; set; } = string.Empty;
public string? Icon { get; set; } = null; public string? Icon { get; set; } = null;
// Optional one-level fan-out. A node with children is a dual-role node: its own Route is a
// real destination (desktop click / mobile tap navigate to it), and Children render as a
// hover dropdown on desktop and an indented sub-list on mobile. Depth is capped at one level
// by convention — children are not themselves expected to carry children.
public IReadOnlyList<PageRoute> Children { get; set; } = [];
public bool HasChildren => Children.Count > 0;
} }
public static class Pages public static class Pages
{ {
// ARCHIVE (→ the release-cardinal /archive browser) carries the three medium modes as Children.
// Above the medium breakpoint the desktop nav flattens them into inline appbar links beside
// ARCHIVE (no popover); below the breakpoint the mobile hamburger renders them as an indented
// sub-list under ARCHIVE. /tracks and /genres are intentionally absent from the nav (8.I) —
// their routes (TracksView, GenresView) remain reachable by direct URL.
public static readonly List<PageRoute> MenuPages = public static readonly List<PageRoute> MenuPages =
[ [
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic }, new()
new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album }, {
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category }, Name = "Archive", Route = "/archive", Icon = Icons.Material.Filled.Inventory2,
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships Children =
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships [
new() { Name = "Cuts", Route = "/cuts", Icon = Icons.Material.Filled.Album },
new() { Name = "Sessions", Route = "/sessions", Icon = Icons.Material.Filled.Piano },
new() { Name = "Mixes", Route = "/mixes", Icon = Icons.Material.Filled.GraphicEq },
],
},
]; ];
public static readonly List<PageRoute> AllPages = public static readonly List<PageRoute> AllPages =
new List<PageRoute> new List<PageRoute>
{ {
new() { Name = "Home", Route = "/", Icon = Icons.Material.Filled.Home } new() { Name = "Home", Route = "/", Icon = Icons.Material.Filled.Home }
@@ -0,0 +1,10 @@
@page "/albums"
@inject NavigationManager Navigation
@* Cuts replaced the standalone /albums route in Phase 9. Old links keep working via a
permanent redirect to /cuts rather than 404ing. *@
@code {
protected override void OnInitialized()
=> Navigation.NavigateTo("/cuts", forceLoad: false, replace: true);
}
+2 -2
View File
@@ -1,6 +1,6 @@
@page "/albums" @page "/cuts"
<PageTitle>DeepDrft Albums</PageTitle> <PageTitle>DeepDrft Cuts</PageTitle>
<div> <div>
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
@@ -1,26 +1,65 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services; using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Models.Common;
namespace DeepDrftPublic.Client.Pages; namespace DeepDrftPublic.Client.Pages;
public partial class AlbumsView : ComponentBase /// <summary>
/// Medium-filtered release gallery. Routed at <c>/cuts</c> (Cut releases) and parameterized by
/// <see cref="Medium"/> so the same component can back any medium's card grid without a fork.
/// Cards open the track gallery filtered to that release's album title, preserving the original
/// /albums ergonomics.
/// </summary>
public partial class AlbumsView : ComponentBase, IDisposable
{ {
[Inject] public required ITrackDataService TrackData { get; set; } private const string PersistKeyPrefix = "albums-view-";
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; } [Inject] public required NavigationManager Navigation { get; set; }
// The medium whose releases this grid shows. Defaults to Cut for the /cuts route; other media
// can reuse this component by passing a different value. Drives both the fetch filter and the
// per-medium persistence key so prerendered state never bleeds across media.
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
private bool _loading = true; private bool _loading = true;
private List<ReleaseDto> _albums = []; private List<ReleaseDto> _albums = [];
private PersistingComponentStateSubscription _persistingSubscription;
private string PersistKey => $"{PersistKeyPrefix}{Medium}";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var result = await TrackData.GetAlbums(); // Bridge the prerendered fetch across the prerender -> WASM seam (see TracksView). Without
if (result is { Success: true, Value: { } albums }) // this, the WASM pass re-fetches and replays the card entrance animations.
_albums = albums; _persistingSubscription = PersistentState.RegisterOnPersisting(PersistAlbums);
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
{
_albums = restored;
_loading = false;
return;
}
var result = await ReleaseData.GetPaged(Medium.ToString().ToLowerInvariant(), page: 1, pageSize: 100);
if (result is { Success: true, Value: { Items: { } items } })
_albums = items.ToList();
_loading = false; _loading = false;
} }
private Task PersistAlbums()
{
if (_albums.Count > 0)
PersistentState.PersistAsJson(PersistKey, _albums);
return Task.CompletedTask;
}
private void OpenAlbum(string album) private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}"); => Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
public void Dispose() => _persistingSubscription.Dispose();
} }
@@ -0,0 +1,116 @@
@page "/archive"
@using DeepDrftModels.Enums
<PageTitle>DeepDrft Archive</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
@* Search + filter affordances are interactive-only: the debounce timer and chip selection
need WASM. During prerender/non-interactive they are hidden, matching TracksView's gate.
The release grid still prerenders so the archive is meaningful before hydration. *@
@if (RendererInfo.IsInteractive)
{
<div class="archive-search-row">
<MudTextField T="string"
Value="@SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search releases or artists"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="archive-search-field"/>
</div>
<div class="archive-filter-row">
<MudToggleGroup T="ReleaseMedium?"
Value="@_selectedMedium"
ValueChanged="@OnMediumSelected"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="archive-medium-toggle">
<MudToggleItem T="ReleaseMedium?" Value="@(null)">All</MudToggleItem>
@foreach (var medium in _media)
{
<MudToggleItem T="ReleaseMedium?" Value="@medium">@MediumLabel(medium)</MudToggleItem>
}
</MudToggleGroup>
</div>
@if (_genres.Count > 0)
{
<div class="archive-filter-row">
<MudChipSet T="string"
SelectedValue="@_selectedGenre"
SelectedValueChanged="@OnGenreSelected"
SelectionMode="SelectionMode.ToggleSelection"
Class="archive-genre-chips">
@foreach (var genre in _genres)
{
<MudChip T="string" Value="@genre.Genre">@genre.Genre</MudChip>
}
</MudChipSet>
</div>
}
}
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (_releases.Count == 0)
{
<div class="archive-empty">
<MudText Typo="Typo.h6">No releases found</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var release in _releases)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="archive-card-center">
<a href="@DetailHref(release)" class="archive-card-link">
<div class="archive-release-card">
@if (!string.IsNullOrEmpty(release.ImagePath))
{
<div class="archive-release-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(release.ImagePath)');">
</div>
}
else
{
<div class="archive-release-cover archive-release-cover--fallback"></div>
}
<div class="archive-release-body">
<MudText Typo="Typo.subtitle1" Class="archive-release-title text-truncate">
@release.Title
</MudText>
<MudText Typo="Typo.caption" Class="archive-release-artist text-truncate">
@release.Artist
</MudText>
</div>
</div>
</a>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@@ -0,0 +1,149 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// The public archive: a release-cardinal searchable browser over every release across all media
/// (Phase 9 §8.H, decision H2). Replaces the former three-card medium overview. Search (Title /
/// Artist), an enum-driven medium filter, and a genre filter narrow the release list; each card
/// routes to its per-medium detail. Mirrors the <see cref="TracksView"/> seam: the unfiltered first
/// page is bridged across the prerender -> WASM boundary so hydration neither re-fetches nor replays
/// the card entrance animations.
/// </summary>
public partial class ArchiveView : ComponentBase, IDisposable
{
private const string PersistKey = "archive-releases";
// A large page covers the full library in one fetch — the archive has no pager, matching the
// medium galleries (AlbumsView / MediumBrowseBase) which also pull pageSize: 100.
private const int PageSize = 100;
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
// Medium filter chips are enum-driven so a fourth medium surfaces a chip from one lookup entry,
// with no markup fork (Phase 9 extension discipline).
private static readonly ReleaseMedium[] _media = Enum.GetValues<ReleaseMedium>();
private bool _loading = true;
private List<ReleaseDto> _releases = [];
private List<GenreSummaryDto> _genres = [];
// null medium == All; null genre == no genre filter. SearchText null/empty == no search.
private ReleaseMedium? _selectedMedium;
private string? _selectedGenre;
private string? SearchText { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
private bool HasActiveFilter =>
_selectedMedium is not null
|| !string.IsNullOrWhiteSpace(_selectedGenre)
|| !string.IsNullOrWhiteSpace(SearchText);
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// The genre chip source is the release-cardinal distinct-genre list (already sourced from the
// release join — see GetDistinctGenresAsync). It only renders interactively, so it is fetched
// lazily on the interactive pass rather than persisted.
if (RendererInfo.IsInteractive)
await LoadGenres();
// The prerendered page is always the unfiltered first page. Restore it only when no filter is
// active; a filtered interactive pass must fetch its own narrowed result instead.
if (!HasActiveFilter
&& PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored)
&& restored is not null)
{
_releases = restored;
_loading = false;
return;
}
await LoadReleases();
}
private async Task LoadGenres()
{
var result = await TrackData.GetGenres();
if (result is { Success: true, Value: { } genres })
_genres = genres;
}
private async Task LoadReleases()
{
_loading = true;
var result = await ReleaseData.GetPaged(
medium: _selectedMedium?.ToString().ToLowerInvariant(),
page: 1,
pageSize: PageSize,
search: SearchText,
genre: _selectedGenre);
_releases = result is { Success: true, Value.Items: { } items }
? items.ToList()
: [];
_loading = false;
}
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Re-fetches with the composed filter (search + medium + genre).
private async Task OnSearchInput(string? value)
{
SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
await LoadReleases();
}
private async Task OnMediumSelected(ReleaseMedium? medium)
{
_selectedMedium = medium;
await LoadReleases();
}
private async Task OnGenreSelected(string? genre)
{
_selectedGenre = string.IsNullOrWhiteSpace(genre) ? null : genre;
await LoadReleases();
}
// Per-medium detail target. Session/Mix open their own detail page; a Cut has no single-release
// detail page, so it opens the track gallery filtered to its release title — the same destination
// AlbumsView's Cut cards use, preserving the established navigation.
private static string DetailHref(ReleaseDto release) => release.Medium switch
{
ReleaseMedium.Session => $"/sessions/{release.Id}",
ReleaseMedium.Mix => $"/mixes/{release.Id}",
_ => $"/tracks?album={Uri.EscapeDataString(release.Title)}",
};
// Display label for a medium filter chip. Centralised so a new medium's label is one entry, not a
// markup change. "DJ Mix" matches the CMS Type-chip wording (§8.D).
private static string MediumLabel(ReleaseMedium medium) => medium switch
{
ReleaseMedium.Cut => "Cuts",
ReleaseMedium.Session => "Sessions",
ReleaseMedium.Mix => "Mixes",
_ => medium.ToString(),
};
private Task Persist()
{
// Only the unfiltered first page is safe to restore onto a later plain /archive visit. A
// filtered render leaves the cache untouched so the bridge never serves narrowed results to an
// unfiltered load.
if (_releases.Count > 0 && !HasActiveFilter)
PersistentState.PersistAsJson(PersistKey, _releases);
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -0,0 +1,83 @@
.archive-view-container {
padding-top: 16px;
}
.archive-search-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
/* archive-search-field rides on MudTextField, whose root is a child Razor component element.
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
::deep .archive-search-field {
max-width: 420px;
width: 100%;
}
.archive-filter-row {
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
padding: 0 0 12px 0;
}
.archive-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.archive-card-link {
text-decoration: none;
color: inherit;
}
.archive-release-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.archive-release-card:hover {
transform: translateY(-4px);
}
.archive-release-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.archive-release-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.archive-release-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* archive-release-title / archive-release-artist ride on MudText (child Razor component); ::deep
pierces into its output since Blazor isolation does not scope-stamp child component roots. */
::deep .archive-release-title {
font-weight: 600;
}
::deep .archive-release-artist {
opacity: 0.7;
}
.archive-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
+6 -7
View File
@@ -59,8 +59,7 @@
</div> </div>
<div class="medium-grid"> <div class="medium-grid">
@* TODO Phase 3.x: wire each card to its format-filtered browse route once /tracks?format= exists *@ <a class="medium-card" href="/cuts">
<div class="medium-card">
<div class="medium-image" style="background-image: url('img/dd-studio.jpg');"> <div class="medium-image" style="background-image: url('img/dd-studio.jpg');">
<div class="medium-scrim"></div> <div class="medium-scrim"></div>
</div> </div>
@@ -69,8 +68,8 @@
<div class="medium-name">Studio Releases</div> <div class="medium-name">Studio Releases</div>
<div class="medium-desc">Composed, layered, and finished &mdash; tracks built to be returned to.</div> <div class="medium-desc">Composed, layered, and finished &mdash; tracks built to be returned to.</div>
</div> </div>
</div> </a>
<div class="medium-card"> <a class="medium-card" href="/sessions">
<div class="medium-image" style="background-image: url('img/dd-live.jpeg');"> <div class="medium-image" style="background-image: url('img/dd-live.jpeg');">
<div class="medium-scrim"></div> <div class="medium-scrim"></div>
</div> </div>
@@ -79,8 +78,8 @@
<div class="medium-name">Live Releases</div> <div class="medium-name">Live Releases</div>
<div class="medium-desc">Performances caught in the moment, unrepeatable and unedited.</div> <div class="medium-desc">Performances caught in the moment, unrepeatable and unedited.</div>
</div> </div>
</div> </a>
<div class="medium-card"> <a class="medium-card" href="/mixes">
<div class="medium-image" style="background-image: url('img/dd-dj.jpeg');"> <div class="medium-image" style="background-image: url('img/dd-dj.jpeg');">
<div class="medium-scrim"></div> <div class="medium-scrim"></div>
</div> </div>
@@ -89,7 +88,7 @@
<div class="medium-name">DJ Mix Releases</div> <div class="medium-name">DJ Mix Releases</div>
<div class="medium-desc">Uninterrupted sets &mdash; one track bleeding into the next, start to finish.</div> <div class="medium-desc">Uninterrupted sets &mdash; one track bleeding into the next, start to finish.</div>
</div> </div>
</div> </a>
</div> </div>
</section> </section>
@@ -0,0 +1,55 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Shared fetch + prerender-bridge logic for the medium browse pages (Sessions, Mixes). Subclasses
/// supply only the <see cref="Medium"/> and <see cref="DetailRoute"/>; this base fetches the paged
/// releases and bridges the prerendered result across the prerender -> WASM seam so the WASM pass
/// does not re-fetch and replay the card animations (see the TracksView seam).
/// </summary>
public abstract class MediumBrowseBase : ComponentBase, IDisposable
{
[Inject] public required IReleaseDataService ReleaseData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
/// <summary>The medium this page browses. Subclass-supplied constant.</summary>
protected abstract ReleaseMedium Medium { get; }
protected bool Loading { get; private set; } = true;
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = [];
private PersistingComponentStateSubscription _persistingSubscription;
private string PersistKey => $"medium-browse-{Medium}";
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
if (PersistentState.TryTakeFromJson<List<ReleaseDto>>(PersistKey, out var restored) && restored is not null)
{
Releases = restored;
Loading = false;
return;
}
var result = await ReleaseData.GetPaged(Medium.ToString().ToLowerInvariant(), page: 1, pageSize: 100);
if (result is { Success: true, Value: { Items: { } items } })
Releases = items.ToList();
Loading = false;
}
private Task Persist()
{
if (Releases.Count > 0)
PersistentState.PersistAsJson(PersistKey, Releases.ToList());
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -0,0 +1,71 @@
@page "/mixes/{Id:long}"
@using DeepDrftPublic.Client.Controls
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
</div>
</div>
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/mixes"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All mixes
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. *@
<MixWaveformVisualizer ReleaseId="@release.Id" />
<div class="mix-detail-foreground">
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/mixes"
BackLabel="All mixes"
ShowMeta="@(hasGenre || hasDate)">
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
</ReleaseDetailScaffold>
</div>
}
@code {
protected override string PersistKey => "mix-detail";
}
@@ -0,0 +1,5 @@
/* Lifts the detail content above the fixed waveform backdrop (z-index: 0). */
.mix-detail-foreground {
position: relative;
z-index: 1;
}
@@ -0,0 +1,15 @@
@page "/mixes"
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
<PageTitle>DeepDrft Mixes</PageTitle>
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
DetailRoute="mixes"
EmptyMessage="No mixes yet" />
@code {
protected override DeepDrftModels.Enums.ReleaseMedium Medium => DeepDrftModels.Enums.ReleaseMedium.Mix;
protected string DetailRoute => "mixes";
}
@@ -0,0 +1,52 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
/// <summary>
/// Shared load + prerender-bridge logic for the single-release detail pages (Session, Mix).
/// Subclasses supply only their markup; this base loads the release through
/// <see cref="ReleaseDetailViewModel"/> and bridges the prerendered release across the prerender ->
/// WASM seam so the WASM pass does not re-fetch (see the TracksView seam). The playable track is
/// re-resolved on a restore miss only.
/// </summary>
public abstract class ReleaseDetailBase : ComponentBase, IDisposable
{
[Parameter] public long Id { get; set; }
[Inject] public required ReleaseDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private PersistingComponentStateSubscription _persistingSubscription;
// Distinct keys per medium so a Session restore never lands on a Mix page.
protected abstract string PersistKey { get; }
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// The bridged payload carries both the release and its resolved track so the interactive
// pass renders identically without a second round-trip.
if (PersistentState.TryTakeFromJson<BridgedDetail>(PersistKey, out var restored) && restored?.Release is not null)
{
ViewModel.Restore(restored.Release, restored.Track);
}
else
{
await ViewModel.Load(Id);
}
}
private Task Persist()
{
if (ViewModel.Release is not null)
PersistentState.PersistAsJson(PersistKey, new BridgedDetail(ViewModel.Release, ViewModel.Track));
return Task.CompletedTask;
}
public void Dispose() => _persistingSubscription.Dispose();
// JSON-serializable bridge payload. Round-trips through PersistentComponentState's serializer.
protected sealed record BridgedDetail(ReleaseDto Release, TrackDto? Track);
}
@@ -0,0 +1,86 @@
@page "/sessions/{Id:long}"
@using DeepDrftPublic.Client.Controls
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-cover">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="320px" />
</div>
<div class="deepdrft-track-detail-masthead">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="70%" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="40%" Height="32px" />
</div>
</div>
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
<div class="d-flex justify-center mt-4">
<MudButton Href="/sessions"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack">
All sessions
</MudButton>
</div>
</div>
</div>
}
else
{
var release = ViewModel.Release;
var heroKey = release.SessionMetadata?.HeroImageEntryKey;
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
var hasGenre = release.Genre is not null;
var hasDate = release.ReleaseDate is not null;
<ReleaseDetailScaffold Title="@release.Title"
Artist="@release.Artist"
Track="@ViewModel.Track"
BackHref="/sessions"
BackLabel="All sessions"
ShowMeta="@(hasGenre || hasDate)">
<Hero>
<div class="session-detail-hero">
@if (!string.IsNullOrEmpty(heroImage))
{
<MudPaper Elevation="2" Class="session-detail-hero-img"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(heroImage)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Piano" Color="Color.Primary" />
</MudPaper>
}
</div>
</Hero>
<MetaContent>
@if (hasGenre)
{
<div>
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Tertiary" Class="deepdrft-genre-chip">
@release.Genre
</MudChip>
</div>
}
@if (hasDate)
{
<div>
<MudText Typo="Typo.overline">Released</MudText>
<MudText Typo="Typo.body1">@release.ReleaseDate!.Value.ToString("MMMM yyyy")</MudText>
</div>
}
</MetaContent>
</ReleaseDetailScaffold>
}
@code {
protected override string PersistKey => "session-detail";
}
@@ -0,0 +1,14 @@
/* Hero-dominant: a wide 16:9 image rather than the square cover used on track detail. */
.session-detail-hero {
margin: 0 auto 2rem;
overflow: hidden;
}
/* session-detail-hero-img rides on MudPaper (child Razor component); ::deep pierces its output. */
::deep .session-detail-hero-img {
width: 100%;
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
@@ -0,0 +1,15 @@
@page "/sessions"
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
<PageTitle>DeepDrft Sessions</PageTitle>
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
DetailRoute="sessions"
EmptyMessage="No sessions yet" />
@code {
protected override DeepDrftModels.Enums.ReleaseMedium Medium => DeepDrftModels.Enums.ReleaseMedium.Session;
protected string DetailRoute => "sessions";
}
+25 -42
View File
@@ -40,50 +40,34 @@ else if (ViewModel.NotFound)
else if (ViewModel.Track is not null) else if (ViewModel.Track is not null)
{ {
var track = ViewModel.Track; var track = ViewModel.Track;
var isThisTrackPlaying = PlayerService.CurrentTrack?.Id == track.Id
&& PlayerService.IsPlaying
&& !PlayerService.IsPaused;
var release = track.Release; var release = track.Release;
var hasMeta = release is not null var hasMeta = release is not null
&& (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null); && (release.Title is not null || release.Genre is not null || release.ReleaseDate is not null);
<div class="deepdrft-track-detail-container"> <ReleaseDetailScaffold Title="@track.TrackName"
Artist="@release?.Artist"
<MudLink Href="/tracks" Typo="Typo.body2" Class="deepdrft-track-detail-back"> Track="@track"
&larr; All tracks BackHref="/tracks"
</MudLink> BackLabel="All tracks"
ShowMeta="@hasMeta">
<MudStack Row AlignItems="AlignItems.Start" Justify="Justify.SpaceBetween" Style="margin: 2rem 0 1.5rem;"> <Hero>
<div class="deepdrft-track-detail-masthead"> <div class="deepdrft-track-detail-cover">
<MudText Typo="Typo.h3">@track.TrackName</MudText> @if (!string.IsNullOrEmpty(release?.ImagePath))
<MudText Typo="Typo.h6" Color="Color.Primary">@release?.Artist</MudText> {
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div> </div>
</Hero>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1"> <MetaContent>
<SharePopover EntryKey="@track.EntryKey" /> @if (hasMeta)
<PlayStateIcon Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack"/>
</MudStack>
</MudStack>
<div class="deepdrft-track-detail-cover">
@if (!string.IsNullOrEmpty(release?.ImagePath))
{ {
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-art"
Style="@($"background-image: url('api/image/{Uri.EscapeDataString(release.ImagePath)}');")" />
}
else
{
<MudPaper Elevation="2" Class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</MudPaper>
}
</div>
@if (hasMeta)
{
<MudDivider />
<div class="deepdrft-track-detail-meta">
@if (release?.Title is not null) @if (release?.Title is not null)
{ {
<div> <div>
@@ -111,8 +95,7 @@ else if (ViewModel.Track is not null)
<MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText> <MudText Typo="Typo.body1">@release.ReleaseDate.Value.ToString("MMMM yyyy")</MudText>
</div> </div>
} }
</div> }
} </MetaContent>
</ReleaseDetailScaffold>
</div>
} }
@@ -1,5 +1,4 @@
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.ViewModels; using DeepDrftPublic.Client.ViewModels;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -12,9 +11,7 @@ public partial class TrackDetail : ComponentBase, IDisposable
[Parameter] public required string EntryKey { get; set; } [Parameter] public required string EntryKey { get; set; }
[Inject] public required TrackDetailViewModel ViewModel { get; set; } [Inject] public required TrackDetailViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; } [Inject] public required PersistentComponentState PersistentState { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription; private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -36,24 +33,6 @@ public partial class TrackDetail : ComponentBase, IDisposable
} }
} }
protected override void OnParametersSet()
{
// The play button's icon reads off the player's live state (CurrentTrack /
// IsPlaying / IsPaused), which mutates outside this component's render path.
// The cascade is IsFixed, so the provider's re-render never reaches us —
// subscribe to the multicast side-channel and re-render on every state change.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
}
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private Task PersistTrack() private Task PersistTrack()
{ {
if (ViewModel.Track is not null) if (ViewModel.Track is not null)
@@ -63,33 +42,5 @@ public partial class TrackDetail : ComponentBase, IDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task PlayTrack() public void Dispose() => _persistingSubscription.Dispose();
{
if (ViewModel.Track is null) return;
var isThisTrack = PlayerService.CurrentTrack?.Id == ViewModel.Track.Id;
// Toggle play/pause if this track is already the active one (playing or paused);
// otherwise start a fresh stream. SelectTrackStreaming is the live entry point —
// the buffered SelectTrack path is dead.
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else
{
await PlayerService.SelectTrackStreaming(ViewModel.Track);
}
}
public void Dispose()
{
_persistingSubscription.Dispose();
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
}
} }
@@ -0,0 +1,33 @@
using DeepDrftModels.DTOs;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Release read abstraction (Phase 9). Both SSR and WASM renders are served by
/// <c>ReleaseClientDataService</c> in this assembly, which delegates to
/// <see cref="Clients.ReleaseClient"/> over HTTP. Components inject this single seam
/// so they do not branch on render mode — mirrors <see cref="ITrackDataService"/>.
/// </summary>
public interface IReleaseDataService
{
/// <summary>Paged releases, optionally narrowed by medium ("cut" | "session" | "mix"), free-text search, and genre.</summary>
Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false,
string? search = null,
string? genre = null);
/// <summary>Single release with both metadata satellites (nulls for non-matching media).</summary>
Task<ApiResult<ReleaseDto>> GetById(long id);
/// <summary>
/// The Mix waveform datum. Success with a value when present; success with a null value when
/// no datum is stored (a valid state, not a failure); failure on any other transport error.
/// </summary>
Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id);
}
@@ -19,7 +19,8 @@ public interface ITrackDataService
bool sortDescending = false, bool sortDescending = false,
string? searchText = null, string? searchText = null,
string? album = null, string? album = null,
string? genre = null); string? genre = null,
long? releaseId = null);
/// <summary>All releases with track counts, title-ascending.</summary> /// <summary>All releases with track counts, title-ascending.</summary>
Task<ApiResult<List<ReleaseDto>>> GetAlbums(); Task<ApiResult<List<ReleaseDto>>> GetAlbums();
@@ -0,0 +1,37 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// <see cref="IReleaseDataService"/> backed by <see cref="ReleaseClient"/> (HTTP to the
/// <c>DeepDrft.API</c> backend). Used on both the SSR prerender and WASM interactive passes —
/// the release read surface is HTTP-only, so there is no separate in-process implementation.
/// </summary>
public class ReleaseClientDataService : IReleaseDataService
{
private readonly ReleaseClient _releaseClient;
public ReleaseClientDataService(ReleaseClient releaseClient)
{
_releaseClient = releaseClient;
}
public Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
string? medium,
int page,
int pageSize,
string? sortColumn = null,
bool sortDescending = false,
string? search = null,
string? genre = null)
=> _releaseClient.GetPaged(medium, page, pageSize, sortColumn, sortDescending, search, genre);
public Task<ApiResult<ReleaseDto>> GetById(long id)
=> _releaseClient.GetById(id);
public Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
=> _releaseClient.GetMixWaveform(id);
}
@@ -26,8 +26,9 @@ public class TrackClientDataService : ITrackDataService
bool sortDescending = false, bool sortDescending = false,
string? searchText = null, string? searchText = null,
string? album = null, string? album = null,
string? genre = null) string? genre = null,
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre); long? releaseId = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre, releaseId);
public Task<ApiResult<List<ReleaseDto>>> GetAlbums() public Task<ApiResult<List<ReleaseDto>>> GetAlbums()
=> _trackClient.GetAlbums(); => _trackClient.GetAlbums();
+6
View File
@@ -20,6 +20,12 @@ public static class Startup
services.AddScoped<ITrackDataService, TrackClientDataService>(); services.AddScoped<ITrackDataService, TrackClientDataService>();
services.AddScoped<TracksViewModel>(); services.AddScoped<TracksViewModel>();
services.AddScoped<TrackDetailViewModel>(); services.AddScoped<TrackDetailViewModel>();
// Release read surface (Phase 9). Same HTTP posture as the track client — both
// WASM and SSR prerender call DeepDrftAPI over the "DeepDrft.API" client.
services.AddScoped<ReleaseClient>();
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>();
} }
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
@@ -0,0 +1,70 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// State for a single-release detail page (Session, Mix). Loads the release and resolves its
/// playable track. The release read surface exposes no track entry directly, so the playable track
/// is resolved through the existing track gallery filtered by the release's id (an exact join) — for
/// Session/Mix that yields the single track. Scoped; reset every flag per <see cref="Load"/> so a
/// reused instance never bleeds across navigations (mirrors TrackDetailViewModel).
/// </summary>
public class ReleaseDetailViewModel
{
private readonly IReleaseDataService _releaseData;
private readonly ITrackDataService _trackData;
public ReleaseDto? Release { get; private set; }
public TrackDto? Track { get; private set; }
public bool IsLoading { get; private set; } = true;
public bool NotFound { get; private set; }
public ReleaseDetailViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
{
_releaseData = releaseData;
_trackData = trackData;
}
/// <summary>Seed state directly from a bridged prerender payload — no fetch.</summary>
public void Restore(ReleaseDto release, TrackDto? track)
{
Release = release;
Track = track;
NotFound = false;
IsLoading = false;
}
public async Task Load(long releaseId)
{
IsLoading = true;
NotFound = false;
Release = null;
Track = null;
try
{
var releaseResult = await _releaseData.GetById(releaseId);
if (releaseResult is not { Success: true, Value: { } release })
{
NotFound = true;
return;
}
Release = release;
// Resolve the playable track via the releaseId-filtered track page — an exact join, not a
// title string (which collides across same-titled releases and breaks on rename). Session/Mix
// releases carry a single track; take the first. A release with no streamable track simply
// leaves Track null (the detail page hides the play affordance).
var trackResult = await _trackData.GetPage(
pageNumber: 1, pageSize: 1, releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } })
Track = items.FirstOrDefault();
}
finally
{
IsLoading = false;
}
}
}
@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies the public release read surface (Phase 9) to DeepDrftAPI so the browser never
/// makes a cross-origin request. Mirrors <see cref="TrackProxyController"/>: the WASM client
/// issues relative <c>api/release/*</c> requests against this host, which forwards them
/// upstream. SSR prerender calls DeepDrftAPI directly via the same named client — no proxy
/// hop on the server side. All forwarded routes are unauthenticated reads.
/// </summary>
[ApiController]
[Route("api/release")]
public class ReleaseProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<ReleaseProxyController> _logger;
public ReleaseProxyController(IHttpClientFactory httpClientFactory, ILogger<ReleaseProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies the paged release list, forwarding the optional medium, search (q), genre, and sort params.</summary>
[HttpGet]
public async Task<ActionResult> GetReleases(
[FromQuery] string? medium = null,
[FromQuery] string? q = null,
[FromQuery] string? genre = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
CancellationToken ct = default)
{
var query = $"api/release?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (!string.IsNullOrWhiteSpace(medium))
query += $"&medium={Uri.EscapeDataString(medium)}";
if (!string.IsNullOrWhiteSpace(q))
query += $"&q={Uri.EscapeDataString(q)}";
if (!string.IsNullOrWhiteSpace(genre))
query += $"&genre={Uri.EscapeDataString(genre)}";
if (!string.IsNullOrWhiteSpace(sortColumn))
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
return await RelayJson(query, "release list");
}
/// <summary>Proxies the Mix waveform datum. A 404 (no datum stored) passes through verbatim.</summary>
[HttpGet("{id:long}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}/mix/waveform", $"release {id} mix waveform", ct);
/// <summary>Proxies a single release. A 404 (no such release) passes through verbatim.</summary>
[HttpGet("{id:long}")]
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
=> await RelayJson($"api/release/{id}", $"release {id}", ct);
// Small JSON payloads, buffered and relayed. Non-success statuses (notably 404) pass through
// so the client renders them as valid states rather than collapsing to a 502.
private async Task<ActionResult> RelayJson(string upstreamPath, string description, CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(upstreamPath, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Description} failed", description);
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI {Description} returned {Status}", description, (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
}
+356
View File
@@ -0,0 +1,356 @@
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Phase 9.5 medium write-path coverage. Exercises the SQL layer that carries the medium through the
/// upload and edit flows (TrackManager + TrackRepository), plus the releaseId track-resolution filter
/// (9.5.C). Runs on the EF in-memory provider, which executes every predicate here in process —
/// release creation, the no-mutation-on-find rule, the medium update + ReleaseType reset, and exact
/// releaseId equality.
///
/// The controller-level form/JSON parse and the ReleaseType-reset conditional (9.5.B) live in
/// TrackController; this fixture asserts the persisted outcome of that logic by driving the same
/// service surface the controller calls (FindOrCreateRelease for upload, ITrackService.Update for
/// the meta edit), so a regression in the data layer that backs the medium write path is caught.
/// </summary>
[TestFixture]
public class MediumWritePathTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private TrackManager CreateManager(TrackRepository repository)
=> new(repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
private static ReleaseDto ReleaseData(string title, string artist, ReleaseMedium medium)
=> new() { Title = title, Artist = artist, Medium = medium };
// 9.5.A — a Session upload creates a release carrying Medium == Session.
[Test]
public async Task FindOrCreateRelease_NewSessionRelease_PersistsMediumSession()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
// 9.5.A — a Mix upload creates a release carrying Medium == Mix.
[Test]
public async Task FindOrCreateRelease_NewMixRelease_PersistsMediumMix()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
[Test]
public async Task FindOrCreateRelease_NewCutRelease_PersistsMediumCut()
{
var manager = CreateManager(CreateRepository());
var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
// upload's medium is authoritative; a Cut-typed follow-up upload must not flip a Session release.
[Test]
public async Task FindOrCreateRelease_ExistingRelease_DoesNotMutateMedium()
{
var repo = CreateRepository();
var manager = CreateManager(repo);
var created = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
// Second add to the same (title, artist) arrives carrying Cut — the find path must ignore it.
var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
// 9.5.B — updating a track's release to a non-Cut medium persists the new medium. Mirrors the
// PUT api/track/meta apply: the controller sets release.Medium, the manager saves the linked release.
[Test]
public async Task Update_FlipsCutReleaseToSession_PersistsMedium()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity
{
Title = "Originally a Cut", Artist = "Artist A",
Medium = ReleaseMedium.Cut, ReleaseType = ReleaseType.EP,
};
var track = new TrackEntity { EntryKey = "ek-1", TrackName = "Track", Release = release };
_context.Tracks.Add(track);
await _context.SaveChangesAsync();
var loaded = (await manager.GetById(track.Id)).Value!;
loaded.Release!.Medium = ReleaseMedium.Session;
// The controller resets ReleaseType to the default when medium goes non-Cut; replicate so the
// edited DTO matches what the controller would persist.
loaded.Release.ReleaseType = ReleaseType.Single;
var result = await manager.Update(loaded);
Assert.That(result.Success, Is.True);
var stored = await CreateRepository().GetReleaseByIdAsync(release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(stored.ReleaseType, Is.EqualTo(ReleaseType.Single), "ReleaseType reset to default for a non-Cut medium");
}
// 9.5.B — the read-path converter already enforces the ReleaseType-only-for-Cut invariant: a
// non-Cut release surfaces a null ReleaseType regardless of the stale column value. This is the
// invariant the write-path reset mirrors, asserted at the single mapping point.
[Test]
public void Convert_NonCutRelease_NullsReleaseTypeOnRead()
{
var sessionWithStaleType = new ReleaseEntity
{
Title = "Session", Artist = "A",
Medium = ReleaseMedium.Session, ReleaseType = ReleaseType.Album,
};
var dto = TrackConverter.Convert(sessionWithStaleType);
Assert.That(dto.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(dto.ReleaseType, Is.Null);
}
// 9.5.C — releaseId filter returns only the tracks of the given release. Built on the repository
// directly to assert the WHERE release_id predicate in isolation.
[Test]
public async Task GetPagedFilteredAsync_WithReleaseId_ReturnsOnlyThatReleasesTracks()
{
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "a2", TrackName = "A-Two", Release = first },
new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
await _context.SaveChangesAsync();
var repo = CreateRepository();
var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };
var result = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "A-One", "A-Two" }));
}
// 9.5.C — two same-titled releases resolve distinctly by id, the exact failure album-title join
// could not survive. Each releaseId returns only its own track.
[Test]
public async Task GetPagedFilteredAsync_SameTitledReleases_ResolveDistinctlyById()
{
var first = new ReleaseEntity { Title = "Untitled", Artist = "Artist A" };
var second = new ReleaseEntity { Title = "Untitled", Artist = "Artist B" };
_context.Tracks.AddRange(
new TrackEntity { EntryKey = "a1", TrackName = "A-One", Release = first },
new TrackEntity { EntryKey = "b1", TrackName = "B-One", Release = second });
await _context.SaveChangesAsync();
var repo = CreateRepository();
var paging = new PagingParameters<TrackEntity> { Page = 1, PageSize = 20, OrderBy = t => t.Id };
var firstResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = first.Id });
var secondResult = await repo.GetPagedFilteredAsync(paging, new TrackFilter { ReleaseId = second.Id });
Assert.That(firstResult.Items.Single().TrackName, Is.EqualTo("A-One"));
Assert.That(secondResult.Items.Single().TrackName, Is.EqualTo("B-One"));
}
// 9.5.C — TrackFilter.IsEmpty accounts for ReleaseId, so a releaseId-only filter is not collapsed
// to a null passthrough by the manager's effectiveFilter guard.
[Test]
public void TrackFilter_WithOnlyReleaseId_IsNotEmpty()
{
Assert.That(new TrackFilter { ReleaseId = 5 }.IsEmpty, Is.False);
Assert.That(new TrackFilter().IsEmpty, Is.True);
}
// 9.7 — the cardinality declaration is the single source of truth read by both the upload service
// and the CMS form collapse. Guard the declared ranges so a drift in MediumRules is caught here.
[Test]
public void MediumRules_CardinalityOf_DeclaresExpectedRanges()
{
var cut = MediumRules.CardinalityOf(ReleaseMedium.Cut);
Assert.That(cut.Min, Is.EqualTo(1));
Assert.That(cut.Max, Is.EqualTo(int.MaxValue));
Assert.That(cut.IsSingleTrack, Is.False);
var session = MediumRules.CardinalityOf(ReleaseMedium.Session);
Assert.That(session.Min, Is.EqualTo(1));
Assert.That(session.Max, Is.EqualTo(1));
Assert.That(session.IsSingleTrack, Is.True);
var mix = MediumRules.CardinalityOf(ReleaseMedium.Mix);
Assert.That(mix.Min, Is.EqualTo(1));
Assert.That(mix.Max, Is.EqualTo(1));
Assert.That(mix.IsSingleTrack, Is.True);
}
// 9.7 — Allows() bands. The first track always fits; a single-track medium rejects the second; an
// unbounded medium accepts any positive count.
[Test]
public void MediumCardinality_Allows_HonoursTheBand()
{
var single = MediumRules.CardinalityOf(ReleaseMedium.Session);
Assert.That(single.Allows(1), Is.True);
Assert.That(single.Allows(2), Is.False);
var many = MediumRules.CardinalityOf(ReleaseMedium.Cut);
Assert.That(many.Allows(1), Is.True);
Assert.That(many.Allows(50), Is.True);
}
// 9.7 — GetReleaseByTitleAndArtist is the read-only peek the upload pre-check reads. It surfaces
// the stored medium and the live-track count without creating a release. Null on miss.
[Test]
public async Task GetReleaseByTitleAndArtist_ExistingRelease_ReturnsMediumAndLiveCount()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
await _context.SaveChangesAsync();
var peek = await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A");
Assert.That(peek.Success, Is.True);
Assert.That(peek.Value, Is.Not.Null);
Assert.That(peek.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(peek.Value.TrackCount, Is.EqualTo(1));
}
[Test]
public async Task GetReleaseByTitleAndArtist_NoSuchRelease_ReturnsNullWithoutCreating()
{
ITrackService manager = CreateManager(CreateRepository());
var peek = await manager.GetReleaseByTitleAndArtist("Nothing Here", "Nobody");
Assert.That(peek.Success, Is.True);
Assert.That(peek.Value, Is.Null);
// The peek must not have created a release for a non-existent (title, artist).
var releases = (await manager.GetReleases()).Value!;
Assert.That(releases, Is.Empty);
}
// 9.7 — the cardinality decision the orchestrator makes, asserted over the SQL-layer seam it reads.
// A Session release that already holds its single track REJECTS a second track-add: the peek's
// live count + 1 exceeds the medium's Max. (The orchestrator's vault write spans the FileDatabase,
// not reachable from this in-memory fixture — see the orphan-avoidance note in the handoff.)
[Test]
public async Task CardinalityDecision_SessionWithOneTrack_RejectsSecondAdd()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Live at the Vault", Artist = "Artist A", Medium = ReleaseMedium.Session };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "Track One", Release = release });
await _context.SaveChangesAsync();
var peek = (await manager.GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
var max = MediumRules.CardinalityOf(peek.Medium).Max;
Assert.That(peek.TrackCount + 1 > max, Is.True, "second track-add to a single-track Session is over-limit");
}
// 9.7 — Mix mirrors Session: a Mix holding its one track rejects a second add.
[Test]
public async Task CardinalityDecision_MixWithOneTrack_RejectsSecondAdd()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Sunset Set", Artist = "DJ B", Medium = ReleaseMedium.Mix };
_context.Tracks.Add(new TrackEntity { EntryKey = "ek-1", TrackName = "The Set", Release = release });
await _context.SaveChangesAsync();
var peek = (await manager.GetReleaseByTitleAndArtist("Sunset Set", "DJ B")).Value!;
var max = MediumRules.CardinalityOf(peek.Medium).Max;
Assert.That(peek.TrackCount + 1 > max, Is.True);
}
// 9.7 — a Cut release accepts the 2nd and Nth track-add: the unbounded Max is never exceeded.
[Test]
public async Task CardinalityDecision_CutWithManyTracks_AcceptsFurtherAdds()
{
var repo = CreateRepository();
ITrackService manager = CreateManager(repo);
var release = new ReleaseEntity { Title = "Studio Album", Artist = "Artist C", Medium = ReleaseMedium.Cut };
_context.Tracks.AddRange(
new TrackEntity { EntryKey = "c1", TrackName = "One", Release = release },
new TrackEntity { EntryKey = "c2", TrackName = "Two", Release = release },
new TrackEntity { EntryKey = "c3", TrackName = "Three", Release = release });
await _context.SaveChangesAsync();
var peek = (await manager.GetReleaseByTitleAndArtist("Studio Album", "Artist C")).Value!;
var max = MediumRules.CardinalityOf(peek.Medium).Max;
Assert.That(peek.TrackCount, Is.EqualTo(3));
Assert.That(peek.TrackCount + 1 > max, Is.False, "Cut is unbounded — a 4th track is admitted");
}
// 9.7 — the first track on a new Session/Mix succeeds: with no existing release the peek returns
// null, so the pre-check never fires and the create path admits the 0→1 add (within 1..1).
[Test]
public async Task CardinalityDecision_FirstTrackOnNewSession_IsAdmitted()
{
ITrackService manager = CreateManager(CreateRepository());
// No release exists yet for this (title, artist).
var peek = await manager.GetReleaseByTitleAndArtist("Brand New Session", "Artist D");
Assert.That(peek.Value, Is.Null, "no release means the cardinality pre-check is skipped — the create path admits the first track");
}
}
+209
View File
@@ -0,0 +1,209 @@
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Query-shape tests for the release-cardinal browse path that backs the public /archive browser
/// (Phase 9 §8.H). Exercises <see cref="ReleaseRepository.GetPagedByMediumAsync"/>: the medium
/// narrowing, the genre filter, and the null/empty-filter passthrough.
///
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That covers
/// the medium predicate, exact genre equality, and the count/ordering — every predicate except the
/// free-text branch. That branch uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function
/// with no in-memory translation, so the SearchText case is a Postgres integration test gated on a DSN
/// (<see cref="GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive"/>). Without a
/// configured database it is ignored rather than asserted against a provider that never runs the
/// predicate — mirroring <see cref="TrackFilterQueryTests"/>.
/// </summary>
[TestFixture]
public class ReleaseBrowseQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private ReleaseRepository CreateRepository()
=> new(_context, NullLogger<ReleaseRepository>.Instance);
private static ReleaseEntity Release(
string title, string artist, ReleaseMedium medium = ReleaseMedium.Cut, string? genre = null)
=> new()
{
Title = title,
Artist = artist,
Medium = medium,
Genre = genre,
};
private async Task SeedAsync(params ReleaseEntity[] releases)
{
_context.Releases.AddRange(releases);
await _context.SaveChangesAsync();
}
private static PagingParameters<ReleaseEntity> DefaultPaging()
=> new() { Page = 1, PageSize = 20, OrderBy = r => r.Id, IsDescending = false };
// Medium filter narrows to a single medium: only releases of that medium are returned, and
// TotalCount reflects the filtered set, not the table.
[Test]
public async Task GetPagedByMediumAsync_WithMedium_ReturnsOnlyThatMedium()
{
await SeedAsync(
Release("Cut One", "A", ReleaseMedium.Cut),
Release("Session One", "B", ReleaseMedium.Session),
Release("Mix One", "C", ReleaseMedium.Mix),
Release("Session Two", "D", ReleaseMedium.Session));
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), ReleaseMedium.Session, filter: null, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Session One", "Session Two" }));
}
// Clearing the medium filter (null) shows every medium — the all-releases archive default.
[Test]
public async Task GetPagedByMediumAsync_WithNullMedium_ReturnsAllMedia()
{
await SeedAsync(
Release("Cut One", "A", ReleaseMedium.Cut),
Release("Session One", "B", ReleaseMedium.Session),
Release("Mix One", "C", ReleaseMedium.Mix));
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(3));
Assert.That(result.Items.Select(r => r.Title),
Is.EquivalentTo(new[] { "Cut One", "Session One", "Mix One" }));
}
// Genre filter narrows across all media: only releases of that exact genre are returned, regardless
// of medium, and TotalCount reflects the filtered set.
[Test]
public async Task GetPagedByMediumAsync_WithGenre_ReturnsOnlyThatGenreAcrossMedia()
{
await SeedAsync(
Release("Cut One", "A", ReleaseMedium.Cut, genre: "Techno"),
Release("Session One", "B", ReleaseMedium.Session, genre: "House"),
Release("Mix One", "C", ReleaseMedium.Mix, genre: "Techno"));
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(r => r.Title), Is.EquivalentTo(new[] { "Cut One", "Mix One" }));
}
// Medium and genre compose: the result is the intersection, narrowed both by medium and genre.
[Test]
public async Task GetPagedByMediumAsync_WithMediumAndGenre_ComposesBothPredicates()
{
await SeedAsync(
Release("Mix Techno", "A", ReleaseMedium.Mix, genre: "Techno"),
Release("Mix House", "B", ReleaseMedium.Mix, genre: "House"),
Release("Cut Techno", "C", ReleaseMedium.Cut, genre: "Techno"));
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), ReleaseMedium.Mix, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(1));
Assert.That(result.Items.Single().Title, Is.EqualTo("Mix Techno"));
}
// A null filter is a passthrough: same items and count as no filter at all. An all-null
// ReleaseFilter is collapsed to null by the manager, so the repository sees null here.
[Test]
public async Task GetPagedByMediumAsync_WithNullFilter_ReturnsAllReleases()
{
await SeedAsync(
Release("Cut One", "A", ReleaseMedium.Cut),
Release("Session One", "B", ReleaseMedium.Session),
Release("Mix One", "C", ReleaseMedium.Mix));
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), medium: null, filter: null, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(3));
}
// Soft-deleted releases never surface in the browse list, with or without a filter.
[Test]
public async Task GetPagedByMediumAsync_ExcludesSoftDeletedReleases()
{
var deleted = Release("Gone", "A", ReleaseMedium.Cut, genre: "Techno");
deleted.IsDeleted = true;
await SeedAsync(
Release("Live", "B", ReleaseMedium.Cut, genre: "Techno"),
deleted);
var repo = CreateRepository();
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), medium: null, new ReleaseFilter { Genre = "Techno" }, CancellationToken.None);
Assert.That(result.TotalCount, Is.EqualTo(1));
Assert.That(result.Items.Single().Title, Is.EqualTo("Live"));
}
// Free-text search across Title and Artist, case-insensitive. EF.Functions.ILike is Npgsql-only and
// does not translate on the in-memory provider, so this runs only against a real Postgres database
// supplied via the DEEPDRFT_TEST_PG environment variable. Without it the test is ignored rather than
// asserted against a provider that cannot execute the predicate.
[Test]
public async Task GetPagedByMediumAsync_WithSearchText_MatchesTitleOrArtistCaseInsensitive()
{
var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG");
if (string.IsNullOrWhiteSpace(dsn))
Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test.");
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseNpgsql(dsn)
.Options;
await using var pg = new DeepDrftContext(options);
await pg.Database.EnsureCreatedAsync();
try
{
pg.Releases.AddRange(
Release("Nightfall", "jazzmin", ReleaseMedium.Session),
Release("All JAZZ Hands", "Brick", ReleaseMedium.Cut),
Release("Silence", "Nobody", ReleaseMedium.Mix));
await pg.SaveChangesAsync();
var repo = new ReleaseRepository(pg, NullLogger<ReleaseRepository>.Instance);
var result = await repo.GetPagedByMediumAsync(
DefaultPaging(), medium: null, new ReleaseFilter { SearchText = "jazz" }, CancellationToken.None);
Assert.That(result.Items.Select(r => r.Title),
Is.EquivalentTo(new[] { "Nightfall", "All JAZZ Hands" }),
"ILike matches 'jazz' case-insensitively in the release Title or Artist");
}
finally
{
await pg.Database.EnsureDeletedAsync();
}
}
}

Some files were not shown because too many files have changed in this diff Show More