597 Commits

Author SHA1 Message Date
daniel-c-harvey 8d1272e36f docs: fix stale anonid.ts path after telemetry module rename
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m1s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m23s
2026-06-26 22:22:05 -04:00
daniel-c-harvey 6a043b622e Merge telemetry-transport-resilience into dev (first-party fetch for play/share; beacon only on unload) 2026-06-26 22:17:27 -04:00
daniel-c-harvey 2af0d8650b fix(telemetry): first-party fetch for play/share, beacon only on unload
Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
2026-06-26 21:11:43 -04:00
daniel-c-harvey ca44979b08 docs: record Opus/derived read-path streaming and index-only opus-status
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m25s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m28s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m59s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m22s
2026-06-26 15:32:18 -04:00
daniel-c-harvey bf5b314aed Merge opus-read-streaming into dev (stream Opus/derived read path; last read-path OOM fix + logger-leak hardening) 2026-06-26 15:16:38 -04:00
daniel-c-harvey afa862a67b fix: move LogInformation inside disposal guard in both GetTrack streaming arms
Prevents an OS handle leak if the logger throws after the FileStream is opened but before File() takes ownership. Also corrects a stale "finally block" comment in the lossless arm — it has always been a catch.
2026-06-26 15:16:29 -04:00
daniel-c-harvey d72263aea1 Stream Opus/derived read path: serve from seekable disk FileStream, never a whole-file byte[]; HasOpusAsync is index-only 2026-06-26 14:58:11 -04:00
daniel-c-harvey 1e17ffc380 docs: record Opus transcode store-path streaming 2026-06-26 14:30:24 -04:00
daniel-c-harvey a98cef1ba7 Merge opus-transcode-streaming into dev (stream Opus transcode store path; last store-path OOM fix) 2026-06-26 14:15:36 -04:00
daniel-c-harvey 4351ae04be Stream Opus transcode source and encoded output; removes last store-path OOM
Source read via streamed vault open + bounded staging copy (index-only duration/extension); encoded output walked from a bounded stream (new OggOpusParser.WalkAsync, byte-identical to the buffer oracle) and stored via streaming vault write. Adds parity tests.
2026-06-26 14:06:33 -04:00
daniel-c-harvey c1f2cafd8b Merge streaming-overhaul into dev (Opus low-data streaming, windowed streaming, HW-accel-off stabilization) 2026-06-26 11:14:59 -04:00
daniel-c-harvey 634eb611eb docs: record streaming-stabilization arc (Opus + HW-accel-off fixes)
Document the back-pressure water marks, genuine end-of-playback gating, rebuffer hysteresis, 48kHz Opus pre-align, decodePressure auto-throttle, and HW-accel detection / lava default-off, plus the off-main-thread-decode fallback note.
2026-06-26 10:59:48 -04:00
daniel-c-harvey d7a373cdb0 Merge HW-acceleration detection (default lava off on software renderer) into streaming-overhaul 2026-06-26 10:42:25 -04:00
daniel-c-harvey 020a945843 Detect HW acceleration; default lava off on software renderer; release probe WebGL context
Probes UNMASKED_RENDERER_WEBGL once per page via a throwaway WebGL context; defaults the lava subsystem off on a positive software-renderer match or total WebGL failure; releases the throwaway context via WEBGL_lose_context after reading the renderer string to avoid exhausting the browser's per-page context limit.
2026-06-26 10:41:07 -04:00
daniel-c-harvey 1aef30f67d Merge visualizer-viewport-framing: anchor ambient visualizer to viewport bottom 2026-06-26 10:37:09 -04:00
daniel-c-harvey 0e8b85bbcb Merge visualizer auto-throttle under decode pressure + instrumentation strip into streaming-overhaul 2026-06-26 06:00:53 -04:00
daniel-c-harvey 374f09150f Auto-throttle visualizer under sustained Opus decode pressure; strip streaming investigation instrumentation 2026-06-26 06:00:05 -04:00
daniel-c-harvey 9bfa921703 docs: reflect streaming upload store path and waveform compute (OOM fix) 2026-06-26 05:55:54 -04:00
daniel-c-harvey 4fe2d564d9 fix: logging levels in Prod 2026-06-26 05:44:19 -04:00
daniel-c-harvey 5d9f410cd8 Merge Wave 2: stream waveform compute to bound large-upload memory into dev 2026-06-26 05:40:06 -04:00
daniel-c-harvey 76f7f389a3 Merge forward-cushion widening + back-pressure diagnostics into streaming-overhaul 2026-06-26 05:17:00 -04:00
daniel-c-harvey 61e185a2f7 audio: widen forward decode cushion 30/15->60/30s + add [BP-DIAG] back-pressure instrumentation
Byte cap (96MB) unchanged as the hard OOM bound; the wider time window only lets sparse Opus use existing memory headroom to ride out decode jitter. Diag logs pin whether the block is back-pressure or decode throughput.
2026-06-25 21:52:20 -04:00
daniel-c-harvey 9347f11ff0 Stream the waveform compute so large uploads no longer buffer the whole file (Wave 2 OOM) 2026-06-25 21:49:11 -04:00
daniel-c-harvey f0d1463619 Merge Opus rebuffer hysteresis + ultra-short-track start fix into streaming-overhaul 2026-06-25 16:02:12 -04:00
daniel-c-harvey aa0b64329f Merge Wave 1: stream audio store path to fix large-upload OOM into dev 2026-06-25 15:59:43 -04:00
daniel-c-harvey 4ab430d232 Fix complete-without-start hang for ultra-short tracks; add Opus rebuffer hysteresis
Tracks whose total audio falls below the playback-start threshold (Opus <1s lead, WAV <6 buffers) silently hung loaded-but-not-playing. After MarkStreamCompleteAsync, call TryStartPlaybackAsync when _streamingPlaybackStarted is still false so the scheduler drains its buffers and fires onPlaybackEnded exactly once.
2026-06-25 15:54:53 -04:00
daniel-c-harvey beec36a382 fix(vault): atomic streamed write via temp→rename, suppress OCE log noise
AddEntryStreamingAsync now writes to a temp file in the same vault directory,
renames it into place (POSIX rename(2) — atomic on Linux), and updates the
index only after the rename succeeds. A client disconnect or I/O fault during
the write leaves the original backing file intact and the index unchanged; the
temp file is cleaned up best-effort on failure. Fixes the data-corruption
regression on the replace path where a cancelled write could truncate the live
backing file after the index update and FileMode.Create already ran.

Also filters OperationCanceledException from error-level logging in
RegisterResourceStreamingAsync — a normal client disconnect is not an error.

Two tests added to AudioStoreStreamingTests covering cancel and fault on the
replace path.
2026-06-25 15:46:58 -04:00
daniel-c-harvey 79bbbd4956 fix(api): stream audio store path to eliminate whole-file buffering (OOM)
Processors now emit a ProcessedAudio plan with a streamed writer instead of a whole-file AudioBinary; vault writes stream via RegisterResourceStreamingAsync. Header parsing is bounded. Wave 2 (waveform/Opus) still re-reads the full file by design.
2026-06-25 15:27:28 -04:00
daniel-c-harvey 48e58c266d Merge false end-of-playback fix (Opus startup underrun gating) into streaming-overhaul 2026-06-25 15:17:17 -04:00
daniel-c-harvey 67422e922d fix(audio): guard underrun/stream-complete against false end-of-playback
pause() clears underrun_ so setStreamComplete can't fire TrackEnded while paused; resetToStart() resets streamComplete. Prior fix: underrun_ park + streamComplete discriminator prevent the Opus-startup false-end. Tests: 18 PlaybackScheduler cases including pause-during-underrun and underrun->resume->genuine-end-once.
2026-06-25 15:16:22 -04:00
daniel-c-harvey 3aed5c129f Merge guard revert + reload-path instrumentation into streaming-overhaul 2026-06-24 23:59:19 -04:00
daniel-c-harvey e98e616997 Remove harmful single-load guard; instrument reload/reset path for double-header-parse hunt 2026-06-24 23:59:09 -04:00
daniel-c-harvey 8fa37f995b Merge Opus 48kHz up-front AudioContext init (root-cause fix) into streaming-overhaul 2026-06-24 23:27:26 -04:00
daniel-c-harvey 0800167511 fix(audio): align AudioContext to 48kHz up front for Opus streams
Opus resolved its 48kHz context lazily on the first chunk, close()ing and rebuilding the live graph mid-decode. Move the recreate into initializeStreaming so it runs before any bytes flow; the lazy call early-returns. WAV path unchanged.
2026-06-24 23:26:42 -04:00
daniel-c-harvey 8a6acd5f5f Merge Opus single-load guard (instrument double-load) into streaming-overhaul 2026-06-24 23:09:08 -04:00
daniel-c-harvey be9de8d77c Collapse duplicate same-track streaming loads to enforce one load per play
A second LoadTrackStreaming for the same in-flight track (UI double-fire, queue re-entry, or JS false-end auto-advance) is now dropped; a different-track load still supersedes. Targets the Opus double-load; keeps load-gen diagnostics.
2026-06-24 23:08:58 -04:00
daniel-c-harvey d686fe48ce Apply stream-quality change live by reloading at current position
Finish the Settings "Apply" behavior so changing streaming quality mid-track
switches format immediately instead of only persisting the cookie for the next
play.

- SettingsMenu reads the AudioPlayerProvider cascade and threads the player into
  StreamQualitySetting as an explicit parameter (the MudMenu panel portals to
  MudPopoverProvider, outside the cascade scope, so a [CascadingParameter] there
  lands null). StreamQualitySetting's Apply persists the cookie, then asks the
  player to reload preserving position.

- Add a "load at timestamp" path to the player rather than restart-from-0-then-
  seek (which audibly played the start and raced the just-started scheduler into
  a crash). ReloadPreservingPositionAsync loads the track in the newly-resolved
  format beginning DIRECTLY at the saved position:
    * new JS resolveStreamOffset(position) resolves the file-absolute byte offset
      with no playback/buffer state (Opus from its sidecar immediately; WAV after
      a header probe),
    * StartFromPositionAsync converges onto the existing seek/refill loop
      (RunSegmentedStreamAsync with a non-null seekPosition) so the decoder
      reinitializes for a header-less Range continuation and starts playback at
      the target,
    * ProbeHeaderAsync feeds the byte-0 segment to the decoder WITHOUT starting
      playback until the WAV header parses (bounded by 256 KB); the probe buffers
      are dropped by the continuation's clearForSeek, so nothing is audible.

- IStreamingPlayerService gains ReloadPreservingPositionAsync; the QueueService
  test fake implements it.
2026-06-24 22:55:03 -04:00
daniel-c-harvey 7adc35dd5d docs: record streaming raw-queue and per-chunk memory bounds
Document the three-layer memory bound (raw queue, decoded queue, network) in the streaming seam after the HW-accel-off OOM fix landed on streaming-overhaul.
2026-06-24 22:09:01 -04:00
daniel-c-harvey 8206c0bdaf Merge streaming OOM cross-format memory bound into streaming-overhaul 2026-06-24 19:54:12 -04:00
daniel-c-harvey aeec582957 Bound decoded forward fill per chunk in streaming read loop
The inter-segment back-pressure gate matched WAV byte density but let a 4MB Opus segment (~100s at 320kbps) decode eagerly into main-process RAM, OOMing the tab with HW accel off. Drain per chunk past high-water, gated on playback start. Adds load-generation diagnostics for the double-load hypothesis.
2026-06-24 19:50:33 -04:00
daniel-c-harvey 036ee1f78e docs: record Phase 21 (windowed streaming) as landed; note Direction A to B pivot
Move Phase 21 from PLAN to COMPLETED with the as-built record, and annotate
the spec that Direction B shipped after WASM fetch buffering defeated A.
2026-06-24 16:05:30 -04:00
daniel-c-harvey c1e6930c70 Merge Phase 21 Direction B (Range-segmented forward fetch) into streaming-overhaul 2026-06-24 15:54:45 -04:00
daniel-c-harvey cc9d20184d Restore IsStreamingMode on recovery; guard superseded-load else-branch
RecoverFromFailedRefill now sets IsStreamingMode=true so the in-place
seek-retry route isn't wedged. The generic-catch unload path is gated on
the loadCts identity, so a superseded load no longer clobbers a newer
operation's state.
2026-06-24 15:37:38 -04:00
daniel-c-harvey e7762e35e8 Fix truncated-segment and mid-stream failure paths in segmented loop
cursor>=totalLength is the sole forward-EOF test; a short non-final body is
a truncation error, not EOF. Mid-stream forward-load failures now invoke
RecoverFromFailedRefill so the scheduler halts instead of a silent false end.
Two regression tests pin both paths.
2026-06-24 15:16:46 -04:00
daniel-c-harvey 11faf8888f Phase 21 Direction B: bound network memory via Range-segmented forward fetch
Replace the open-ended forward GET with sequential bounded bytes=start-end
segments, the next fetched only when the scheduler drains below low-water,
so the browser holds ~one segment regardless of file size. Seek converges
on the same loop. Strip BP-DIAG.
2026-06-24 13:20:37 -04:00
daniel-c-harvey adbd376d42 Fix stale spacer-observe comment: drop visualizer/clipping ref, name spacer as sole consumer 2026-06-24 10:40:52 -04:00
daniel-c-harvey cb899a2913 Anchor ambient visualizer to viewport bottom; occlude via z-index not clip
Drop the --player-height bottom inset so the fixed visualizer fills the
viewport; the inset player bar no longer leaves a page-background gap. The
spacer now occludes via opaque page-surface + z-index. Visualizer no longer
reads --player-height, so spacer.ts coalescing is removed.
2026-06-24 09:06:45 -04:00
daniel-c-harvey def297e7d9 Merge Phase 21.4 streaming fix + back-pressure diagnostics into streaming-overhaul 2026-06-24 09:02:34 -04:00
daniel-c-harvey 369cb86437 Add [BP-DIAG] back-pressure instrumentation for Phase 21.4 browser run
Temporary, grep-tagged diagnostics at the read-loop pause, the scheduler
latch, and the chunk-result path to show whether ProductionPaused latches,
reaches C#, and parks the loop. Strip once the cause is confirmed.
2026-06-24 09:00:38 -04:00
daniel-c-harvey c7629c15a4 Enable WASM response streaming on audio media fetch
Without SetBrowserResponseStreamingEnabled the browser buffers the whole
body before yielding, so the Phase 21.2 read-loop pause backpressured an
already-downloaded payload. Set it on both the initial and seek/refill
requests; safe no-op on the SSR path.
2026-06-24 08:45:33 -04:00
daniel-c-harvey 9c95a5f23e Merge Phase 21.3 (seek-back-past-window refill + AC6 recovery) into streaming-overhaul 2026-06-23 23:58:51 -04:00
daniel-c-harvey b93881cd66 21.3 review fixes: guard superseded-seek failures; restore post-recovery retry
C6/AC8: IsStillActiveSeek() predicate guards all three SeekBeyondBuffer
failure exits, so a superseded seek never recovers over a newer seek's
state. AC6: empty scheduler routes to seekBeyondBuffer so a same-target
retry (seek or play) refetches instead of no-oping.
2026-06-23 23:55:28 -04:00
daniel-c-harvey af4cb186f3 Phase 21.3: seek-back-past-window refill + clean refill-failure recovery
Seek-back past the retained tail reuses the existing seek-beyond-buffer
Range path (per-path resolver). A failed refill now halts the scheduler
into a paused-but-loaded state (AC6) instead of a silent false end.
2026-06-23 23:43:17 -04:00
daniel-c-harvey 121983b19d Merge Phase 21.2 (streaming back-pressure) into streaming-overhaul 2026-06-23 23:29:35 -04:00
daniel-c-harvey 29e8747c69 21.2 review remediation: pause-spin, OQ7 comment, rename, C2 cross-check
Skip the back-pressure interop poll while paused (UC5). Document complete()
draining the stash in full by design. Rename scheduler isProductionPaused to
evaluateProductionPause (latch-advancing); window exposure name unchanged.
2026-06-23 23:28:42 -04:00
daniel-c-harvey 518479e7ae Phase 21.2: back-pressure to bound the unplayed decoded region
Shared scheduler fill signal (forward water-marks + hard byte cap) pauses
the C# read loop above high-water and, for Opus, stops the demux/decode
feed so WebCodecs queues stay near-empty. Routes through the existing
cancellation discipline; releases the latch on clear/seek.
2026-06-23 23:16:08 -04:00
daniel-c-harvey a2becf45d6 Merge Phase 21.1 (PlaybackScheduler partial eviction) into streaming-overhaul 2026-06-23 22:56:48 -04:00
daniel-c-harvey 07f29a8216 Reconcile eviction comment wording; add handleSourceEnded cascade test (Phase 21.1)
The inclusive <= bound is correct; comments now say 'at or behind'. New
test drives eviction through the real onended trigger with a live mid-array
source pinning the frontier.
2026-06-23 22:49:12 -04:00
daniel-c-harvey ed606d94c7 Add partial eviction to PlaybackScheduler (Phase 21.1)
Drop already-played buffers from the front while advancing the time
anchor so position/index bookkeeping stays exact. Shared by both decode
paths, no format branch. Back-retain is a config seam for 21.2.
2026-06-23 22:39:05 -04:00
daniel-c-harvey ccf7d3dbe3 docs: reconcile Phase 21 spec with as-built Phase 18 (two decode paths)
Window both the WAV StreamDecoder and Opus WebCodecs paths feeding one PlaybackScheduler — shared eviction, per-path back-pressure; reuse the now-live index-driven Opus seek for refill. Drops stale approximate-seek language; adds OQ6/OQ7.
2026-06-23 22:01:49 -04:00
daniel-c-harvey bbcf8be677 docs: record Phase 18 (Opus low-data streaming) in COMPLETED; stage PLAN for Phase 21 2026-06-23 21:48:39 -04:00
daniel-c-harvey 8902ce4d63 Merge Opus duration/seek/visualizer fix into streaming-overhaul 2026-06-23 21:25:26 -04:00
daniel-c-harvey eb58ae4a72 Fix Opus duration reporting so seekbar and visualizer work
Surface the sidecar duration on the first Opus chunk instead of gating it on the first decoded buffers; C# locks UI Duration on chunk 1, and async WebCodecs decode left it at 0 — killing seek and the duration-gated visualizer.
2026-06-23 21:23:43 -04:00
daniel-c-harvey d80b777e9f Merge WebCodecs streaming Opus decoder (+ AC9 seek re-sync) into streaming-overhaul 2026-06-23 21:03:02 -04:00
daniel-c-harvey 5a75da1769 fix: AC9 seek fine re-sync + deterministic decoder drain (WebCodecs Opus)
Seek now trims the lead-in so playback lands at the requested time, not the page start; decoder drain polls decodeQueueSize (bounded) instead of a single timeout. Minor cleanups.
2026-06-23 20:57:05 -04:00
daniel-c-harvey 7f3fb74126 Replace broken per-segment Opus decode with WebCodecs AudioDecoder streaming pipeline 2026-06-23 17:42:06 -04:00
daniel-c-harvey d0118997b6 Merge Opus capability-probe fix into streaming-overhaul 2026-06-23 17:03:07 -04:00
daniel-c-harvey 5b78efaad4 fix: replace hand-assembled Opus probe blob with real ffmpeg/libopus output
Previous probe sample had invalid Ogg page CRC32s, so Chrome/Firefox rejected it and the capability check always returned false. New 176-byte libopus sample has verified-correct CRCs. Adds structural-validity tests.
2026-06-23 16:57:49 -04:00
daniel-c-harvey 81d4b42b72 Merge Phase 18.6 Track A (public Settings menu + streaming-quality toggle) into streaming-overhaul 2026-06-23 14:52:28 -04:00
daniel-c-harvey 77c6c42c94 remediate: replace eval cookie writes with safe JS helper + add tests (18.6 Track A)
Both SettingsCookieService and DarkModeCookieService now call window.DeepDrftSettings.setCookie (new Interop/settings/settings.ts) instead of eval. New tests cover SettingsServiceBase parse/format round-trip and the PreferenceAwareStreamingPlayerService invariant (Lossless skips probe; LowData inherits base).
2026-06-23 14:17:34 -04:00
daniel-c-harvey ab75bbf6c1 Merge Phase 18.6 Track B (CMS Opus status: backfill badge + Post-Processing) into streaming-overhaul 2026-06-23 14:09:48 -04:00
daniel-c-harvey 59f48bb8cb feature: CMS Opus status surfaces — backfill missing-N badge + upload Post-Processing phase (18.6) 2026-06-23 14:06:21 -04:00
daniel-c-harvey c63c7ca033 feature: Phase 18.6 Track A — public Settings menu + streaming-quality toggle 2026-06-23 14:06:19 -04:00
daniel-c-harvey e5366bc4ec Merge Phase 18.5 (Opus end-to-end integration + Backfill-Opus) into streaming-overhaul 2026-06-23 12:52:07 -04:00
daniel-c-harvey 2bde4908d7 Wire Opus end-to-end playback + Backfill-Opus action (Phase 18.5)
Player picks Opus when the browser can decode it and a sidecar exists (else lossless), injecting the sidecar before stream init; seek reuses the same format. Adds the Backfill-Opus bulk API endpoint + CMS action.
2026-06-23 12:39:13 -04:00
daniel-c-harvey 1e063d95f4 chore: Trigger CI
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m21s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m33s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m17s
Package install tarball / package (push) Successful in 7s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m30s
2026-06-23 08:50:22 -04:00
daniel-c-harvey dce5530890 Merge Phase 18.4 (OpusFormatDecoder + index-based seek) into streaming-overhaul 2026-06-23 08:48:06 -04:00
daniel-c-harvey 8afcd3784f Merge Phase 18.3 (Opus delivery transport) into streaming-overhaul 2026-06-23 08:44:53 -04:00
daniel-c-harvey 261289c1b8 feature: OpusFormatDecoder — Ogg-page-aligned segmenting, sidecar parser, accurate index-based seek (Phase 18.4) 2026-06-23 08:34:39 -04:00
daniel-c-harvey 740d01a67f feature: Phase 18.3 — Opus delivery transport (?format= stream + seek sidecar endpoint) 2026-06-23 08:34:37 -04:00
daniel-c-harvey 1fdbec2533 Merge cors-manager-origin into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m15s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m35s
2026-06-23 08:21:33 -04:00
daniel-c-harvey 70842cb576 docs: add production install checklist 2026-06-23 08:15:56 -04:00
daniel-c-harvey f2a0d39521 config: add app.deepdrft.com to API CORS allowlist 2026-06-23 08:15:55 -04:00
daniel-c-harvey e807ddb91b Merge Phase 18.2 (Opus format resolution + sidecar lookup contract) into streaming-overhaul 2026-06-23 07:49:28 -04:00
daniel-c-harvey 19793ba1c3 feature: Opus format resolution + sidecar lookup contract (Phase 18.2) 2026-06-23 07:45:06 -04:00
daniel-c-harvey 1bda2b7bea docs: reflect Phase 23 SEO crawl directives as landed
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftManager / Deploy (push) Successful in 1m23s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-23 07:40:57 -04:00
daniel-c-harvey 8773803712 feature: og default image 2026-06-23 07:40:42 -04:00
daniel-c-harvey 3cc11bcbb5 Merge p23-w1-t2-cms-noindex into dev
Phase 23 Track B: make DeepDrftManager uncrawlable — static robots.txt (Disallow: /) + blanket noindex meta in the CMS head. No env gate; the CMS is always uncrawlable.
2026-06-23 07:36:01 -04:00
daniel-c-harvey 0ba4fc6597 Merge p23-w1-t1-public-crawl-endpoints into dev
Phase 23 Track A: env-gated /robots.txt + /sitemap.xml on DeepDrftPublic. Thin controller + pure builders, reuses api/release + ReleaseRoutes + SeoOptions.BaseUrl. Non-prod uncrawlable; sitemap loc equals page canonical by construction.
2026-06-23 07:35:52 -04:00
daniel-c-harvey 7a0ccdd784 fix: correct WalkPageSize to 100 (actual server PageSize cap) and update comment 2026-06-23 07:33:24 -04:00
daniel-c-harvey e845dc3496 Merge ffmpeg host provisioning into streaming-overhaul 2026-06-23 07:30:21 -04:00
daniel-c-harvey ba064cc136 provision ffmpeg on DeepDrftAPI host for Opus transcode
Phase 18.1 needs ffmpeg (libopus). Add it to bootstrap.sh apt prereqs and a
preflight guard in install.sh; resolves via the systemd user unit's default
PATH (/usr/bin), no config change.
2026-06-23 07:30:13 -04:00
daniel-c-harvey ca057dc630 chore: make DeepDrftManager uncrawlable and noindex (Phase 23.3)
Static robots.txt (Disallow: /) in wwwroot + blanket noindex meta in App.razor head. No env gate — the CMS is always uncrawlable. Defense in depth per spec OQ-C1.
2026-06-23 07:23:49 -04:00
daniel-c-harvey 5f4807cc4a feature: Phase 23 Track A — env-gated /robots.txt + /sitemap.xml public crawl endpoints 2026-06-23 07:23:42 -04:00
daniel-c-harvey b3dadbb572 Merge Phase 18.1 (Opus ingest transcode + seek-index sidecar) into streaming-overhaul 2026-06-23 07:13:46 -04:00
daniel-c-harvey 9a4b79d377 docs: spec Phase 23 — SEO crawl directives (sitemap.xml, robots.txt, CMS noindex) 2026-06-23 07:10:20 -04:00
daniel-c-harvey 33383cd675 Merge p22-w2-jsonld-type-fix into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m20s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m25s
Fix JSON-LD @type serialization: concrete nodes were emitting a bare Type alongside @type because the attribute sat only on the abstract base override. Validator now clean.
2026-06-23 06:57:44 -04:00
daniel-c-harvey 56f7013314 fix: put [JsonPropertyName("@type")] on each concrete JsonLdNode override
System.Text.Json emitted both "@type" and a bare "Type" because the attribute was only on the abstract base member. Adds regression assertions for all node types.
2026-06-23 06:57:05 -04:00
daniel-c-harvey 6add30a4ff fix: Wave 18.1 review — pre-skip subtraction, t=0 anchor, PreSkip in sidecar, stderr on cancel 2026-06-23 06:55:31 -04:00
daniel-c-harvey 33d6f34d8a feature: Phase 18.1 — derive Opus 320 + seek-index sidecar at ingest
Background-job transcode (ffmpeg/libopus) after source store; pure C# Ogg
walker builds the 0.5s-bucketed granule→byte seek index + captures the
OpusHead/OpusTags setup header into a per-track sidecar in a new track-opus
vault. Best-effort, additive, regenerated on replace-audio.
2026-06-23 06:30:10 -04:00
daniel-c-harvey 2653e62eeb docs: reflect Phase 22 SEO metadata component as landed 2026-06-23 06:21:52 -04:00
daniel-c-harvey 45bd599bdd Merge p22-w1-seo-metadata-component into dev
Phase 22: parameterized SEO metadata component for the public site — SeoHead + typed JSON-LD builders, per-medium release schema, env-gated noindex (beta uncrawled), inline-safe JSON-LD escaping.
2026-06-23 06:16:31 -04:00
daniel-c-harvey f976af0f7c fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex
Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
2026-06-23 06:10:03 -04:00
daniel-c-harvey f3b89ca9d7 feature: Phase 22 SEO metadata component for public site
One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
2026-06-23 05:41:55 -04:00
daniel-c-harvey 8752fc0c98 docs: resolve Phase 18 OQ7 seek-index granularity to 0.5s buckets 2026-06-23 05:36:25 -04:00
daniel-c-harvey 274d0ace62 Merge install-prep-analysis: installer prompts for AuthBlocks:Email:From 2026-06-23 05:28:17 -04:00
daniel-c-harvey e3a4364b8c docs(plan): Phase 18 OQ resolutions + VBR-safe accurate Opus seek model 2026-06-23 05:26:58 -04:00
daniel-c-harvey 564b704803 fix(installer): prompt for and write AuthBlocks:Email:From
Without this field, DeepDrftAPI throws InvalidOperationException on
startup. Adds the EMAIL_FROM prompt after EMAIL_TOKEN, writes "From"
into the Email JSON object, and unsets the variable on cleanup.
2026-06-23 05:26:48 -04:00
daniel-c-harvey 6af6677a12 docs: spec Phase 22 — parameterized SEO metadata component (public site) 2026-06-23 05:12:31 -04:00
daniel-c-harvey 1bdaeaa164 docs(plan): add Phase 18 Opus low-data streaming; resolve Phase 21 OQ5 (no MSE) 2026-06-23 04:58:21 -04:00
daniel-c-harvey a84a99c309 docs: spec Phase 21 — windowed streaming buffer for bounded client memory 2026-06-23 00:14:44 -04:00
daniel-c-harvey 2c1571057a feature: Manager Menu Styles and Page Titles
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m23s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m4s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-22 23:04:49 -04:00
daniel-c-harvey 0b7d8e41e7 Merge account-nav-menu into dev 2026-06-22 22:42:48 -04:00
daniel-c-harvey 4833935925 feature: About Bio text 2026-06-22 22:41:39 -04:00
daniel-c-harvey 7917d56af3 feature: Manager Logos 2026-06-22 22:41:30 -04:00
daniel-c-harvey 1fd63fe368 Add AccountNavMenu to CmsLayout nav drawer 2026-06-22 22:39:21 -04:00
daniel-c-harvey 4e1f540945 Merge bump-cerebellum-final into dev 2026-06-22 22:28:10 -04:00
daniel-c-harvey 1ed518b018 chore: bump Cerebellum stack to NetBlocks 10.3.32 / BlazorBlocks 10.3.35 / AuthBlocks 10.3.39
Delivers the ResultDtoBase.From() null-crash fix to DeepDrft's
Users/Registrations pages.
2026-06-22 22:27:57 -04:00
daniel-c-harvey 7c41aa678d Revert "Merge bisect-match-skipper into dev"
This reverts commit 475e5e671c, reversing
changes made to 0d1da9e63c.
2026-06-22 12:47:02 -04:00
daniel-c-harvey 475e5e671c Merge bisect-match-skipper into dev 2026-06-22 12:24:00 -04:00
daniel-c-harvey 9971474403 bisect: pin DeepDrftHome to Skipper's known-good package versions
AuthBlocks* → 10.3.35, BlazorBlocks* → 10.3.32. Diagnostic downgrade to
isolate null-ref crash on Users/Registrations pages.
2026-06-22 12:23:19 -04:00
daniel-c-harvey 0d1da9e63c docs: note Phase 20 visualizer-flash fix (coalesced --player-height publish) 2026-06-22 08:38:55 -04:00
daniel-c-harvey d47c186045 Merge p20-theater-visualizer-flash into dev 2026-06-22 08:36:05 -04:00
daniel-c-harvey 670eaab34d fix(visualizer): coalesce --player-height publish so Theater ease doesn't thrash the WebGL backing store 2026-06-22 08:19:53 -04:00
daniel-c-harvey c58b1c9386 Merge bump-cerebellum-deps into dev 2026-06-21 11:55:40 -04:00
daniel-c-harvey 450204cdbf Bump Cerebellum packages to fix null-Items crash on Users/Registrations pages
AuthBlocks → 10.3.38, BlazorBlocks → 10.3.34, NetBlocks → 10.3.31.
Pulls server-side null-Items guard (AuthBlocks) and BlazorBlocks render
guard. Direct refs for BlazorBlocks/NetBlocks raised to avoid NU1605
downgrade conflicts with AuthBlocks 10.3.38's transitive requirements.
2026-06-21 11:50:05 -04:00
daniel-c-harvey 5c22c1626a docs: reflect Phase 20 Wave 2 theater refinements (full-screen body, eased collapse, playing-release scoping) 2026-06-21 10:18:19 -04:00
daniel-c-harvey 8628fbf215 Merge Theater Mode refinements (Phase 20 Wave 2) into dev 2026-06-21 09:23:56 -04:00
daniel-c-harvey a23a22a2a3 fix(css): visibility transition 0s->0.45s so allow-discrete defers collapse flip to end of ease-out 2026-06-21 09:20:18 -04:00
daniel-c-harvey 6e12d0161a fix(theater): replace max-height collapse with grid-rows + visibility; fix keyboard-focus leak when collapsed 2026-06-21 09:12:24 -04:00
daniel-c-harvey 9716092805 feat(theater): full-screen detail body, eased content collapse, playing-release scoping
Detail bodies fill 100vh below the nav so the visualizer reads full-screen; Theater toggle eases page content and the player-bar now-showing panel in/out instead of popping (reduced-motion honored); Theater only applies to the currently-playing release.
2026-06-21 08:59:09 -04:00
daniel-c-harvey a577df88dd docs: reflect Phase 20 Theater Mode landing in PLAN, COMPLETED, CLAUDE.md, and spec status 2026-06-20 22:17:58 -04:00
daniel-c-harvey 011dbe8d81 Merge Theater Mode (Phase 20) into dev 2026-06-20 22:12:23 -04:00
daniel-c-harvey 2fc2d4eb6d test: fix PascalCase nit in CoerceTheaterMode_BothOff_TheaterBecomesFalse 2026-06-20 22:09:34 -04:00
daniel-c-harvey 14f3af41e4 fix(theater): auto-exit Theater Mode when both visualizer subsystems are disabled
Adds CoerceTheaterMode() to WaveformVisualizerControlState; ToggleLava/ToggleWaveform
call it before NotifyChanged so all observers see consistent state in one Changed cycle.
Covers the dead-end escape route bug (Phase 20 review finding).
2026-06-20 22:03:39 -04:00
daniel-c-harvey fa01b9c8e0 feat(public): add Theater Mode to release detail pages
Toggle left of the lava popover hides release content so the visualizer fills
the surface; player bar grows to carry the playing release's cover, title, and
share. State on WaveformVisualizerControlState; pages and bar observe it.
2026-06-20 21:51:30 -04:00
daniel-c-harvey 835fb71337 docs(plan): mark Phase 20 Theater Mode scoped after sign-off 2026-06-20 21:40:56 -04:00
daniel-c-harvey 021801999c docs(plan): add Phase 20 Theater Mode spec and roadmap entry 2026-06-20 19:08:44 -04:00
daniel-c-harvey 54cba7eea0 docs(queue): sync client CLAUDE.md to deque cleanup — cached QueueItems, scaffold/StreamNow PLAY routing 2026-06-20 19:05:18 -04:00
daniel-c-harvey fbaf545c90 Merge queue-deque-redesign into dev
Two-level deque queue model + five bug fixes, plus review cleanup.
2026-06-20 19:01:07 -04:00
daniel-c-harvey d3f89c494a fix: Waveform Visualizer Controls layout 2026-06-20 18:56:53 -04:00
daniel-c-harvey c3ec3acafa fix(queue): route scaffold masthead PLAY through queue; cache QueueItems snapshot 2026-06-20 18:51:30 -04:00
daniel-c-harvey 214f708e65 feat(queue): two-level deque model — PLAY prepends, add appends, last-track-end empties
Fixes five queue bugs: Playlist relabel, last-track-empties, dormant-seed-from-player on first add, immediate panel reactivity, and front/back deque semantics. Adds JumpTo for row jumps.
2026-06-20 15:26:37 -04:00
daniel-c-harvey 5058c72375 fix(rcl): commit theme.js so RCL interop JS ships via MapStaticAssets
Deploy DeepDrftManager / Build & Publish (push) Successful in 2m0s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m26s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
theme/ was missing from the per-module .gitignore allowlist (only
parallax/ and knob/ were re-included), so theme.js never got committed,
was absent from publish output, and 404'd at runtime. Broaden the
allowlist to the whole DeepDrftShared.Client/wwwroot/js/ tree so every
compiled RCL interop module ships automatically.
2026-06-20 12:31:49 -04:00
daniel-c-harvey f5edcba7b2 feature: Waveform Controls Restructuring
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m27s
2026-06-20 03:12:41 -04:00
daniel-c-harvey 64e1f71e18 docs: reflect gas-lamp self-coloring in theming section
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m26s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m56s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
GasLampLit now uses an explicit #2A5C4F frame fill; the removed dark-only nav rule is no longer described as live.
2026-06-20 03:11:33 -04:00
daniel-c-harvey 7807d4ebe1 Merge theme-icon-followups into dev
Fix PlayStateIcon green-on-green chip and gas-lamp frame in dark theme.
2026-06-20 03:07:40 -04:00
daniel-c-harvey 4410132409 docs: correct PlayStateIcon compiled-selector specificity tuple (0,4,0) to (0,5,0)
The [b-xxx] Blazor scope attribute is a fifth class/attribute simple selector; the prior count dropped it.
2026-06-20 03:06:59 -04:00
daniel-c-harvey 00ff9e2702 fix(dark-theme): PlayStateIcon glyph beats .dd-accent-icon; GasLampLit self-colored frame
PlayStateIcon.razor.css adds a .mud-icon-root rule !important so the play chip always shows
navy on moss-green in dark. GasLampLit frame path changed from currentColor to #2A5C4F;
dead nav dark rule removed.
2026-06-20 03:03:18 -04:00
daniel-c-harvey bb086e5869 docs: update Provision User nav target to /useradmin/users/new (AuthBlocks 10.3.37) 2026-06-20 02:53:53 -04:00
daniel-c-harvey 674d772986 Merge p19-w6-authblocks-1037-adopt into dev (adopt AuthBlocks 10.3.37: account-creation normalization + paged-route null-guard fix; repoint Provision User nav) 2026-06-20 02:39:58 -04:00
daniel-c-harvey ee296db7f6 Merge theme-accent-icon-consolidation into dev
Consolidate per-site dark-icon overrides into reusable .dd-accent-icon treatment; fix hero glyphs in dark.
2026-06-20 02:35:08 -04:00
daniel-c-harvey 8a4da2f0b9 chore: bump Cerebellum.AuthBlocks to 10.3.37 in DeepDrftAPI
Picks up the server-side null-guard fix in RouteHelpers.GetPage/GetAll and UserService.GetPage, resolving the ArgumentNullException on the CMS User Accounts and Registrations pages.
2026-06-20 02:33:38 -04:00
daniel-c-harvey c28a2b1cf5 docs: correct specificity arithmetic and spinner-clause accuracy in .dd-accent-icon comments
Glyph rule is (0,3,0) > (0,1,0) — beats .mud-secondary-text on specificity, not source order.
ReleaseHeroOverlay spinner comment now distinguishes dead glyph clauses from the live spinner clause that produced the intentional light delta.
2026-06-20 02:32:12 -04:00
daniel-c-harvey 1427c92092 feat(manager): adopt AuthBlocks.Web 10.3.37; repoint Provision User nav to /useradmin/users/new
10.3.37 retires /account/superregister in favour of the new canonical /useradmin/users/new route. Bump the package and update the CmsLayout nav link accordingly.
2026-06-20 02:31:38 -04:00
daniel-c-harvey 2fbb1c9b95 fix(theme): green hero Share/Play/Queue glyphs in dark via shared .dd-accent-icon
Fold Session/Mix hero glyphs into the reusable accent-icon treatment so they reach
the glyph (beating .mud-secondary-text) green-accent in both themes; drop the dead
wrapper white rule and the redundant dark-only hero override. Light pixel-identical.
2026-06-20 02:21:11 -04:00
daniel-c-harvey 4c56eededc Merge dark-theme-hero-buttons into dev
Green hero Share/Play/Queue, lava-lamp, and gas-lamp affordances in dark theme.
2026-06-20 01:51:01 -04:00
daniel-c-harvey f9d99b2c98 fix: dark-theme hero buttons green in dark mode; correct source-order comment
Both ::deep and global selectors are (0,3,0); override wins on source order
(deepdrft-styles.css linked after scoped bundle in App.razor).
2026-06-20 01:49:55 -04:00
daniel-c-harvey 59608a23c5 Merge dark-theme-green-buttons into dev
Green Play/Share/Queue buttons on Cut detail in dark theme.
2026-06-20 01:30:20 -04:00
daniel-c-harvey 2ddc57edb1 fix(dark-theme): green Play/Share/Queue buttons in Cut detail
Color.Secondary renders off-white in dark mode, making the filled Play
button and the Share/Queue icon buttons in .cut-detail-actions and track
rows unreadable. Override to green (--deepdrft-primary) in dark only;
hero-overlay icons untouched.
2026-06-20 01:29:59 -04:00
daniel-c-harvey 0bb656a512 docs: log Phase 18 Wave 5 light-glass panel theming in COMPLETED
Record the new theme-aware --deepdrft-panel-* token family making the queue,
visualizer, and privacy overlays light-glass in light theme (dark-glass unchanged in
dark), and the lifted dark-glass exemption.
2026-06-20 01:12:04 -04:00
daniel-c-harvey bb5a1fcad4 fix: Privacy Message 2026-06-20 01:10:19 -04:00
daniel-c-harvey 896b37792e Merge light-glass-panels into dev
Queue, waveform-visualizer control deck, and privacy overlays now render as light
translucent glass with legible dark text in light theme via a new theme-aware
--deepdrft-panel-* token family; dark-glass charcoal unchanged in dark theme.
Lifts the prior dark-glass exemption for these three panels.
2026-06-20 01:08:30 -04:00
daniel-c-harvey 2619fc67c8 fix: wire --deepdrft-panel-text-muted into queue rows; refresh stale light/dark comments
Replace opacity-reduced color on .deepdrft-queue-position and .deepdrft-queue-artist with
var(--deepdrft-panel-text-muted) so the token earns its place in the family.
Update .wvc-section-label and .waveform-visualizer-control-icon comments to reflect
theme-aware (not static-light) behavior.
2026-06-20 01:06:58 -04:00
daniel-c-harvey 4c14c67c33 feat(theme): light-glass panels in light theme
Queue, visualizer control deck, and privacy overlays now bind a theme-aware
--deepdrft-panel-* family (surface/text/text-muted/border/row-hover): light
translucent glass with dark text in light theme, unchanged dark-glass charcoal
in dark. Tokens re-declared in body.deepdrft-theme-dark for the body-portaled overlays.
2026-06-20 00:59:22 -04:00
daniel-c-harvey 494668bf24 Merge p19-w5-mailtrap-testinbox into dev (wire optional Mailtrap TestInbox sandbox routing in DeepDrftAPI) 2026-06-20 00:36:11 -04:00
daniel-c-harvey c4e22c706c docs: record sponsor approval of NewUser normalization decisions
Mark brief §5 decisions resolved (all recommendations accepted 2026-06-20): NewUser canonical for direct provision, SuperRegister deleted + redirected, Registration label tidied.
2026-06-20 00:34:44 -04:00
daniel-c-harvey c747f3200f docs: clarify TestInbox placeholder in authblocks.example.json
Empty string gave no hint what value is expected; <sandbox-id> signals the Mailtrap sandbox inbox ID that must be supplied.
2026-06-20 00:34:41 -04:00
daniel-c-harvey 1dd1646cce docs: record popover-surface retune and portaled-popover body-class bridge
Note the 4%/bluer-navy --deepdrft-popover-surface values, the new
--deepdrft-popover-surface-dark source token, the theme TS interop module, and the
<body>-class bridge in CLAUDE.md; log Phase 18 Wave 4 in COMPLETED.md.
2026-06-20 00:32:13 -04:00
daniel-c-harvey 6bbec2fc8e Merge popover-surface-retune into dev
Retune public-site popover surfaces: light reads as a near-page-background light
surface (8%->4% navy), dark skews bluer (navy-mid + green-accent). Root cause: popovers
portal to <body>, outside the theme wrapper; MainLayout now stamps the theme class on
<body> via a TS interop helper so portaled popovers receive the dark token.
2026-06-20 00:28:20 -04:00
daniel-c-harvey 0c22ce8f09 docs: add AuthBlocks NewUser/SuperRegister normalization team brief
Brief the AuthBlocks team to make NewUser the canonical direct-provision page (absorbing SuperRegister) and keep Registration as the invite flow.
2026-06-20 00:28:06 -04:00
daniel-c-harvey 67645cfd05 wire Mailtrap TestInbox config in DeepDrftAPI
Read AuthBlocks:Email:TestInbox from config (no throw — optional sandbox key). Add TestInbox placeholder to authblocks.example.json.
2026-06-20 00:27:01 -04:00
daniel-c-harvey 2591710f09 refactor: replace eval dark-mode body-class with TS theme interop helper
Extracts setBodyThemeClass into DeepDrftShared.Client/Interop/theme/theme.ts;
MainLayout lazy-imports the compiled module and calls it, matching the
established knob/parallax IJSObjectReference pattern. DisposeAsync added.
2026-06-20 00:26:52 -04:00
daniel-c-harvey 30999b038c fix: gate OnAfterRenderAsync body-class JS call; hoist dark popover token
Only stamps body class on firstRender or _isDarkMode change; adds base call.
Hoists duplicate dark popover mix value to --deepdrft-popover-surface-dark in :root;
both .deepdrft-theme-dark and body.deepdrft-theme-dark reference it via var().
2026-06-20 00:21:53 -04:00
daniel-c-harvey b5106d090f fix: popover surface — body-class bridge for portal scope, retune light/dark
MudBlazor popovers portal to <body>, outside the theme wrapper, so the dark token
was unreachable. MainLayout now stamps deepdrft-theme-dark on <body>. Light: 8%->4%
navy (near page background); dark: navy-mid + 20% green-accent (bluer).
2026-06-20 00:15:42 -04:00
daniel-c-harvey a2ed334d0d docs: mark ModelView DI briefs resolved (shipped in BlazorBlocks 10.3.33 / AuthBlocks 10.3.36) 2026-06-19 23:58:14 -04:00
daniel-c-harvey 9300c794b4 Merge p19-w4-authblocks-1036-bump into dev (AuthBlocks 10.3.36: JWT refresh fix + ModelView DI fix; drop stopgap) 2026-06-19 23:57:08 -04:00
daniel-c-harvey 95dd48018a chore: bump AuthBlocks to 10.3.36, drop EditModalSaveContextHolder stopgap
10.3.36 fixes JWT refresh for idle sessions and registers EditModalSaveContextHolder via AddBlazorBlocksWeb() — making the manual stopgap in DeepDrftManager/Program.cs redundant. BlazorBlocks direct refs (10.3.30) resolved without conflict; left unchanged.
2026-06-19 23:54:10 -04:00
daniel-c-harvey c21b85afdf docs: note BatchUpload captures user id at init to survive mid-session token expiry 2026-06-19 23:39:10 -04:00
daniel-c-harvey 234a57d6b7 Merge cms-upload-userid-capture into dev (capture upload-form user id at init so mid-session token expiry can't discard a composed release) 2026-06-19 23:28:45 -04:00
daniel-c-harvey 4bec507aab docs: split ModelView DI brief into per-team BlazorBlocks and AuthBlocks briefs
Two self-contained team briefs with explicit ship-ordering; original trimmed to an index pointing to both.
2026-06-19 23:26:56 -04:00
daniel-c-harvey a30d15f79d fix: correct BatchUpload comments — no prerender pass on this host, single init pass on live interactive circuit 2026-06-19 23:23:16 -04:00
daniel-c-harvey b90604d311 docs: add brief for upstream BlazorBlocks ModelView DI-registration fix
EditModalSaveContextHolder is required by ModelView but registered by no BlazorBlocks/AuthBlocks setup extension. Recommends AddBlazorBlocksWeb() called from ConfigureAuthServices.
2026-06-19 23:16:29 -04:00
daniel-c-harvey 77d0562b08 feature: Dark Theme Home & About Styles 2026-06-19 23:15:26 -04:00
daniel-c-harvey aeda7e67a8 Merge p19-w3-editmodal-holder into dev (register EditModalSaveContextHolder so AuthBlocks Users/Registrations pages render) 2026-06-19 23:12:36 -04:00
daniel-c-harvey bd9c67fc65 fix: capture upload-form user id at init, not submit, so token expiry mid-session can't discard a composed release 2026-06-19 23:12:26 -04:00
daniel-c-harvey 62fe27224c fix: register EditModalSaveContextHolder in DeepDrftManager DI
ModelView has a required [Inject] of this type; without it navigating to /useradmin/users or /useradmin/registrations terminated the circuit. Matches the registration pattern from SkipperHaven.
2026-06-19 23:10:08 -04:00
daniel-c-harvey 0708bb7352 docs: correct pending-registration route references to api/pendingregistration 2026-06-19 22:53:59 -04:00
daniel-c-harvey e6d5b9b77a Merge p19-w2-mailtrap-fromaddress into dev (wire AuthBlocks:Email:From so invite-email sends succeed) 2026-06-19 22:48:06 -04:00
daniel-c-harvey 04847391ad fix: wire AuthBlocks:Email:From into EmailConnection.FromAddress
Mailtrap rejected invite sends because FromAddress was never populated. Adds the missing config assignment alongside Host/Token, and documents the From key in authblocks.example.json.
2026-06-19 22:45:49 -04:00
daniel-c-harvey 3d71b6836e docs: correct Wave 2 hero detail, add Wave 3 note to Phase 18 COMPLETED entry 2026-06-19 22:08:04 -04:00
daniel-c-harvey 833b5a921e Merge p18-w3-hero-dark-legibility into dev (Phase 18 Wave 3 — hero text + button dark-mode legibility) 2026-06-19 22:05:58 -04:00
daniel-c-harvey 3bf95538bd fix: dark btn-primary hover uses green-interactive (#429d6a) not green-light (#2A5C4F) so contrast improves on hover 2026-06-19 22:05:32 -04:00
daniel-c-harvey eb7e977f3c feature: AppBar clearance & Theming 2026-06-19 22:04:57 -04:00
daniel-c-harvey 0b8593950b docs: reflect Phase 19.1/19.2 landing (CMS nav drawer + auth-state DefaultLayout) 2026-06-19 22:04:02 -04:00
daniel-c-harvey 51ac1a76de fix(dark): hero text + button legibility on navy ground (Phase 18 W3)
Bind page-text/page-text-muted tokens directly in hero base rules (drop
:global overrides); dark-mode overrides for btn-primary (green-accent fill)
and btn-ghost (white text, light border).
2026-06-19 22:00:26 -04:00
daniel-c-harvey 949bccfb8e Merge p19-w1-t2-public-route-layout into dev (auth-state-driven DefaultLayout for public CMS routes) 2026-06-19 21:57:44 -04:00
daniel-c-harvey cfaf63468d Merge p19-w1-t1-cms-nav-drawer into dev (CMS nav drawer surfacing AuthBlocks user-admin + SuperRegister) 2026-06-19 21:57:37 -04:00
daniel-c-harvey d6dcd82a53 fix: gate SuperRegister nav link to UserAdmin role
Provision User nav link was visible to all authenticated CMS users but its target page is UserAdmin-gated. Wraps the MudNavLink in HierarchicalRoleAuthorizeView matching the UserAdminMenu pattern.
2026-06-19 21:57:00 -04:00
daniel-c-harvey 3485acf3a8 feat: auth-state-driven DefaultLayout for CMS public routes
Resolve Routes.razor DefaultLayout from cascaded AuthenticationState so unauthenticated AuthBlocks pages (/account/login, /account/register) render in lean CmsHomeLayout instead of the authenticated CmsLayout shell.
2026-06-19 21:16:42 -04:00
daniel-c-harvey c04c2a9e98 docs: reflect Phase 18 landing; fix palette-file claim in CLAUDE.md 2026-06-19 21:16:40 -04:00
daniel-c-harvey f1276faabc feat(cms): add nav drawer to CmsLayout
Add a MudDrawer with app-bar toggle linking Catalogue, Releases, Upload, SuperRegister, and the self-gating UserAdminMenu fragment so user-admin pages are reachable.
2026-06-19 21:06:47 -04:00
daniel-c-harvey 6029e226d5 Merge p18-w2-theme-followups into dev (Phase 18 Wave 2 — appbar navy, dark hero legibility, true page ground, green-on-green play chip) 2026-06-19 21:01:24 -04:00
daniel-c-harvey 135cc48301 fix: correct AppbarBackground dark-mode comment — appbar is lighter than #0D1B2A page ground, not the ground itself 2026-06-19 21:00:44 -04:00
daniel-c-harvey 54766fd5fc docs: correct Phase 19 to CMS-only host model (drop DeepDrftPublic track)
All three AuthBlocks account paths live on DeepDrftManager; public registration is an unauthenticated CMS route like the CMS login. Path 2 reduces to a single auth-state-driven DefaultLayout fix (SkipperHaven pattern).
2026-06-19 20:46:14 -04:00
daniel-c-harvey fcc95b9195 style: Phase 18 Wave 2 — appbar navy, dark hero legibility, true page ground, green-on-green play chip 2026-06-19 20:32:21 -04:00
daniel-c-harvey 042641d841 docs: expand Phase 19 to all three AuthBlocks registration paths + reset brief
Cover admin provision-now, public self-service redeem, and admin invite-by-email across CMS + public-site tracks. Add standalone AuthBlocks password-reset team brief.
2026-06-19 19:18:53 -04:00
daniel-c-harvey 0358df82ac feat: Player & Menu Styles 2026-06-19 19:18:40 -04:00
daniel-c-harvey 0f7088fe86 Merge p18-w1-theme-dark-remediation into dev (Phase 18 dark-mode token pass) 2026-06-19 19:12:26 -04:00
daniel-c-harvey 5408d0779c fix: scope play-glyph override to dark mode, fix connect-option hover, tokenize bio placeholder, correct popover comment 2026-06-19 19:04:05 -04:00
daniel-c-harvey abe94953b9 docs: add Phase 19 user-management CMS wiring plan + product note 2026-06-19 19:02:40 -04:00
daniel-c-harvey 03fdcda054 style: theme-aware token pass for dark-mode surfaces (Phase 18)
Re-point neutral page surfaces, play-chip, and default popover from constant brand tokens to theme-aware aliases defined twice in deepdrft-tokens.css. Decorative navy/green sections and bespoke dark-glass panels untouched. Appbar-navy symptom deferred (palette C#, out of CSS scope).
2026-06-19 18:12:35 -04:00
daniel-c-harvey 5298cab9b1 feature: Re-enable Dark Mode Toggle & App Bar Styles & Mobile App Bar Fixes
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m9s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m31s
2026-06-19 17:48:26 -04:00
daniel-c-harvey e05d93a67b docs: document upload staging directory and Upload:StagingPath config 2026-06-19 17:45:52 -04:00
daniel-c-harvey fd4fdd2624 docs: add Phase 18 theme/dark-mode remediation plan + product note 2026-06-19 17:41:11 -04:00
daniel-c-harvey 639f4741e6 Merge upload-temp-disk-fix into dev (stage large audio uploads on data disk instead of /tmp) 2026-06-19 17:37:26 -04:00
daniel-c-harvey d7071fdbc2 fix: always delete staging file on mid-copy abort
Build the staging path before the copy in both UploadTrack and ReplaceAudio so the finally block deletes it on cancellation or IO error, not only on success.
2026-06-19 17:36:06 -04:00
daniel-c-harvey 37cf19c405 fix: stage audio uploads on data disk instead of /tmp
Relocate both the framework multipart buffer (via ASPNETCORE_TEMP) and the controller staging file to a configurable data-disk directory, so large WAV/FLAC/MP3 uploads no longer fail on the host's small tmpfs.
2026-06-19 17:25:51 -04:00
daniel-c-harvey 37bbfb947f docs: note footer PRIVACY button + centered MudOverlay privacy modal 2026-06-19 17:09:37 -04:00
daniel-c-harvey 261b11436e Merge privacy-footer-overlay into dev (PRIVACY footer button + centered overlay note)
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m56s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-19 17:02:17 -04:00
daniel-c-harvey 280dbbcbc9 style: DRY footer btn CSS, add trailing newline, drop wrong section ordinal 2026-06-19 16:59:01 -04:00
daniel-c-harvey ce17a685e0 docs: reflect Phase 17 Wave 17.3 landing; Phase 17 complete 2026-06-19 16:48:48 -04:00
daniel-c-harvey 64379c8901 feat: move footer privacy note behind PRIVACY overlay button 2026-06-19 16:48:46 -04:00
daniel-c-harvey 1f8802363c Merge p17-w3-embed-panel into dev (Phase 17 Wave 17.3: Fixed embed queue panel + collapse/resize handshake) 2026-06-19 16:38:38 -04:00
daniel-c-harvey 58cdb4d9dc fix: isolate multi-embed resize handshake with per-snippet token
ForRelease mints a per-call token used as the iframe id and threaded into the src as EmbedId; the host script matches on it so multiple embeds resize independently. ForTrack unchanged.
2026-06-19 16:32:59 -04:00
daniel-c-harvey 97cce691db docs: document upload duplicate-detection rule, release/exists endpoint, and FindOrCreateRelease WasCreated contract 2026-06-19 16:25:50 -04:00
daniel-c-harvey d0be26bb3e Merge upload-duplicate-detection into dev (block duplicate-release uploads by title+artist) 2026-06-19 16:22:28 -04:00
daniel-c-harvey 466084b5a3 feat: Phase 17.3 — Fixed embed queue panel with collapse/expand iframe resize (OQ1 Option A)
Read-only inline queue panel below the release embed's player bar; row-jump reuses PlayRelease. ForRelease mints a taller iframe plus a postMessage resize listener for the collapse toggle; ForTrack unchanged.
2026-06-19 16:21:45 -04:00
daniel-c-harvey 558ff4b4c6 fix: close TOCTOU in CREATE path; add anti-forgery, loose-track, and case-sensitivity tests
FindOrCreateRelease now returns (ReleaseDto, bool WasCreated); the CREATE path in UploadAsync
rejects WasCreated=false as a duplicate rather than silently attaching on a lost race.
2026-06-19 15:55:08 -04:00
daniel-c-harvey bd85507308 Block duplicate-release uploads by (title, artist): pre-flight check + server 409 backstop, with within-batch Cut attach via releaseId 2026-06-19 15:44:41 -04:00
daniel-c-harvey fbd298b9c3 docs: reflect Phase 17 Wave 2 (docked overlay + Add-to-Queue) and Phase 16.5 capstone landing
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 3m2s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m24s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftManager / Deploy (push) Successful in 1m31s
Deploy DeepDrftPublic / Deploy (push) Successful in 2m0s
2026-06-19 15:42:17 -04:00
daniel-c-harvey 3da6591194 docs(phase-16): reflect live Plays card in stats CLAUDE.md
HomeStatsDto gains TotalPlays + UniqueListeners; StatsController now composes ITrackService + IEventService (best-effort play/listener reads).
2026-06-19 15:41:17 -04:00
daniel-c-harvey da60296cf8 Merge p17-w2-t2-add-to-queue into dev (Phase 17 Wave 17.4: Add-to-Queue affordance) 2026-06-19 15:35:19 -04:00
daniel-c-harvey 4320ea8029 Merge p17-w2-t1-docked-overlay into dev (Phase 17 Wave 17.2: docked queue overlay + ClearUpcoming) 2026-06-19 15:34:59 -04:00
daniel-c-harvey 678d3f66ad Merge p16-w5-t2-privacy-footer into dev (anonId privacy disclosure footer line) 2026-06-19 15:33:28 -04:00
daniel-c-harvey be04e53a97 Merge p16-w5-t1-plays-card into dev (Phase 16 Wave 16.5: home Plays-card live) 2026-06-19 15:31:37 -04:00
daniel-c-harvey 58b30d3c13 feat(footer): add anonId privacy disclosure line
Wraps existing footer row in .deepdrft-footer-main and adds a
.deepdrft-footer-privacy paragraph below it with the approved
Variant 1 copy. Mono fine-print at 0.55 rem / 70% opacity.
2026-06-19 15:26:07 -04:00
daniel-c-harvey be1a55fd37 feat(stats): flip home Plays card live (Phase 16.5)
Add TotalPlays + UniqueListeners to HomeStatsDto, composed at
StatsController from IEventService (no migration). Card reads via
existing persistent-state-bridged round-trip.
2026-06-19 15:26:07 -04:00
daniel-c-harvey 9d0ce99a5d fix: PlayRelease always materialises a defensive copy so Items alias can't wipe the queue on jump; add aliasing regression test 2026-06-19 15:23:20 -04:00
daniel-c-harvey 1d387c2a34 feat(player): add append-only "Add to Queue" buttons beside detail-page play affordances
Cut header (release → EnqueueRange), Cut track rows + Session/Mix hero (track → Enqueue). Reuses existing engine path; add is not play.
2026-06-19 15:18:38 -04:00
daniel-c-harvey fe3819f378 feat(player): docked queue overlay with reorder, remove, jump, and clear-upcoming
Add a Queue toggle to the docked player bar opening a centered editable queue
overlay. New additive QueueService.ClearUpcoming keeps the playing track while
dropping the rest. Current track is non-removable.
2026-06-19 15:18:25 -04:00
daniel-c-harvey cfcc2693f2 docs: reflect raised upload cap (~1.86 GB) and 1200s response timeout 2026-06-19 15:14:07 -04:00
daniel-c-harvey 621c4f9cb3 docs(phase-16): draft anonId privacy-note copy; note deferred Postgres integration harness 2026-06-19 15:10:15 -04:00
daniel-c-harvey 67eeb38529 Merge fix-large-upload-cap into dev (raise CMS upload cap to ~1.86 GB + nginx timeouts) 2026-06-19 15:08:48 -04:00
daniel-c-harvey 9aa66e8a62 docs: resolve remaining seven Phase 17 open questions (all 11 now closed) 2026-06-19 15:08:39 -04:00
daniel-c-harvey 3b9ca700c9 raise upload size cap to ~1.86 GB and nginx timeouts to 1200s
Raise RequestSizeLimit/MultipartBodyLengthLimit on upload+replace-audio,
MaxUploadBytes in BatchUpload/BatchEdit, and DefaultResponseTimeoutSeconds to
1200s. Add client_max_body_size 2000m and proxy_read/send_timeout 1200s to the
nginx manager/public confs.
2026-06-19 15:02:49 -04:00
daniel-c-harvey 4317a2f9e7 docs(phase-16): record 16.2 absorption + 16.3 anonId landing
PLAN/COMPLETED mark 16.2 absorbed into 16.1 and 16.3 landed (no migration). Folder CLAUDE.md files reflect anonId now accepted/persisted + the distinct-listener queries.
2026-06-19 14:57:23 -04:00
daniel-c-harvey 297805b5a8 Merge p16-w3-anonid into dev (Phase 16 Wave 16.3: unique-listener anonId layer) 2026-06-19 14:43:46 -04:00
daniel-c-harvey 944f23a88c docs: reflect Phase 17 Wave 17.1 landing (queue Move/RemoveAt + QueueList) 2026-06-19 14:43:36 -04:00
daniel-c-harvey 75e5d99aea Merge p17-w1-queue-engine-list into dev (Phase 17 Wave 17.1: queue Move/RemoveAt + shared QueueList) 2026-06-19 14:38:25 -04:00
daniel-c-harvey c084efa78e feat(phase-16.3): light up anonId unique-listener layer
Mint a first-party localStorage anonId, thread it onto play/share beacons,
persist it via EventController, and add all-time distinct-listener counts
(site/track/release). Storage columns + indexes already existed from 16.1.
2026-06-19 14:37:55 -04:00
daniel-c-harvey f296bbdf00 Add queue Move/RemoveAt + dormant-Enqueue coherence and shared QueueList (Phase 17.1) 2026-06-19 14:32:08 -04:00
daniel-c-harvey ebbaa3f84f docs: resolve four Phase 17 open questions (OQ1/OQ4/OQ8/OQ10), defer ReleaseGallery card affordance 2026-06-19 13:42:19 -04:00
daniel-c-harvey a715f4b28d Merge p16-w1-foundation into dev (Phase 16 Wave 16.1: anonymous play & share telemetry substrate) 2026-06-19 13:34:01 -04:00
daniel-c-harvey 90555dc4e0 docs: spec Phase 17 player-bar queue view (queue button, overlay/embed modes, add-to-queue) 2026-06-19 13:29:57 -04:00
daniel-c-harvey 0fbf81b23e Merge branch 'dev' into p16-w1-foundation
# Conflicts:
#	DeepDrftPublic.Client/Controls/SharePopover.razor.cs
2026-06-19 13:28:50 -04:00
daniel-c-harvey 4114aa0be4 docs: reflect embed new-tab title link and embed queue skip buttons 2026-06-19 13:22:29 -04:00
daniel-c-harvey 884ccab826 Merge p16-embed-newwindow into dev (embed: new-tab title link + queue skip buttons) 2026-06-19 13:17:36 -04:00
daniel-c-harvey 3c1998de4f feat(embed): show skip-prev/next buttons in embed when queue exists 2026-06-19 13:10:50 -04:00
daniel-c-harvey 622ee940f4 fix(phase-16): forward X-Forwarded-For from EventProxyController so the API rate limiter partitions per client IP
Proxy chains any inbound XFF with the connection IP before relaying upstream; UseForwardedHeaders resolves it to the limiter's partition key. Documents the EventRepository first-play counter race (unique index is the backstop).
2026-06-19 13:09:21 -04:00
daniel-c-harvey 18e171213c feat: open player title link in new tab when embedded (Fixed mode) 2026-06-19 13:08:04 -04:00
daniel-c-harvey e9c61bac1a docs: reflect whole-release embeds, queue armed-idle state, and per-track share 2026-06-19 13:00:13 -04:00
daniel-c-harvey dbd90ee52a feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
2026-06-19 12:59:00 -04:00
daniel-c-harvey 1b7861e168 Merge p16-release-embed into dev (whole-release embeds + per-track share) 2026-06-19 12:55:11 -04:00
daniel-c-harvey 098020db32 feat: add per-track SharePopover to Cut detail track rows 2026-06-19 12:08:27 -04:00
daniel-c-harvey 912256d99a Add whole-release embeds to FramePlayer and un-gate the release embed share affordance
The queue gains an armed-but-idle state (Arm/Start) so a release embed stages track 0 prerender-safe, then queues the full release on first play and auto-advances.
2026-06-19 12:05:35 -04:00
daniel-c-harvey 1931574ad4 Merge gitattributes-knob-eol into dev (pin knob.js to LF, stop CRLF churn on Windows checkout) 2026-06-19 11:39:40 -04:00
daniel-c-harvey 25aba1cbb7 docs(phase-16): resolve decisions D1-D7; re-sequence waves bottom-up, card last 2026-06-19 11:32:24 -04:00
daniel-c-harvey 81d0028f2b fix: pin knob.js to LF in .gitattributes to stop CRLF churn on Windows checkout 2026-06-19 11:32:18 -04:00
daniel-c-harvey 62007a6517 fix: Icons
Deploy DeepDrftManager / Build & Publish (push) Successful in 2m9s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m18s
Deploy DeepDrftManager / Deploy (push) Successful in 1m31s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-19 11:15:19 -04:00
daniel-c-harvey 13b07beb0b fix: Styles & Links & Content 2026-06-19 11:15:09 -04:00
daniel-c-harvey 7711c5067c docs: reflect DurationSeconds write on replace-audio
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 4m3s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m35s
Replace path now updates SQL DurationSeconds via unconditional SetDuration; document SetDuration vs null-guarded UpdateDuration and correct the stale 'SQL is not written' note.
2026-06-19 10:15:59 -04:00
daniel-c-harvey eaa71ebea3 Merge replace-audio-duration-sync into dev (sync DurationSeconds on audio replace via unconditional SetDuration) 2026-06-19 10:13:19 -04:00
daniel-c-harvey e8359d5473 fix: replace-audio duration write now unconditional via SetDuration
UpdateDuration's null guard matched zero rows for tracks that already had a duration (all normally-uploaded tracks). Add SetDurationAsync/SetDuration/ITrackService.SetDuration with no null guard; fail on zero rows. ReplaceAudioAsync now calls SetDuration.
2026-06-19 04:19:39 -04:00
daniel-c-harvey 7265754c27 fix: write DurationSeconds to SQL after replace-audio vault swap 2026-06-18 15:03:38 -04:00
daniel-c-harvey abc832467d docs(plan): add Phase 16 spec — anonymous play & share tracking
Design spec for the telemetry layer behind the home-hero Plays card:
completion-bucketed plays, shares, optional anonymous unique listeners
under a no-PII constraint. Seven open decisions flagged for Daniel.
2026-06-18 14:28:02 -04:00
daniel-c-harvey 47919a226e feature: Home page graphics
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m22s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m23s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m16s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m35s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-18 14:25:08 -04:00
daniel-c-harvey 933b7145e5 Merge knob-js-deploy-fix into dev (commit compiled RCL knob JS so it ships in publish output) 2026-06-18 13:18:43 -04:00
daniel-c-harvey f21647423a docs: document track replace-audio endpoint and edit-form gating 2026-06-18 13:17:30 -04:00
daniel-c-harvey df7acd9e80 docs: reflect live home-hero stats (duration column, stats endpoint, backfill, NowPlayingStats wiring) 2026-06-18 13:14:52 -04:00
daniel-c-harvey 3a4db834ac fix: track compiled RCL knob JS for MapStaticAssets deployment 2026-06-18 13:14:09 -04:00
daniel-c-harvey d12151278a Merge cms-track-replace-gating into dev
Replace track audio in CMS edit form + gate last-track delete.
2026-06-18 13:14:08 -04:00
daniel-c-harvey ca90302f21 fix: register-new-then-remove-old in ReplaceTrackAudioAsync; replace wording in timeout messages; doc comment on ExistingTrackCount
On partial failure the old path deleted the original audio before
confirming the new write succeeded. Now: load old extension, register
new audio first (original untouched on failure), then clean up stale
backing file only on success and only when extension changed.
2026-06-18 13:11:59 -04:00
daniel-c-harvey 16784b37f2 feat(cms): replace track audio in edit form, gate last-track delete
Swap a track's audio by EntryKey (metadata/release/position preserved, waveform regenerated); hide per-track remove on a release's sole persisted track so it can only be replaced or release-deleted.
2026-06-18 12:59:56 -04:00
daniel-c-harvey e9e6b6054f Merge nowplaying-stats into dev (live home-hero aggregate stats + track duration column) 2026-06-18 12:58:54 -04:00
daniel-c-harvey 8fa330fbd3 fix: exclude live tracks under soft-deleted releases from home stats cut/mix figures 2026-06-18 12:42:23 -04:00
daniel-c-harvey 5f0422a263 Wire NowPlayingStats to live aggregates: add SQL track duration column, stats endpoint, and duration backfill 2026-06-18 11:53:49 -04:00
daniel-c-harvey 8ddecb4acc about styles
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m57s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-17 22:35:41 -04:00
daniel-c-harvey 17a35247c1 docs: mark About page follow-ups (2) + (4) resolved in COMPLETED.md
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m20s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m54s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-17 22:27:10 -04:00
daniel-c-harvey fb987acc18 Merge p12-w5-khabran-bio into dev (Khabran bio + multi-paragraph bio render) 2026-06-17 22:18:06 -04:00
daniel-c-harvey b524b8e6ec feature: Images edits 2026-06-17 22:18:01 -04:00
daniel-c-harvey 9cfc31f725 content(about): wire Khabran's bio + multi-paragraph render
Bio embedded as \n\n-delimited string; render splits on that boundary
into per-para <p class="bio-body">. Adjacent-sibling margin keeps
stacked paragraphs readable. Daniel's single-para bio is unaffected.
2026-06-17 22:16:18 -04:00
daniel-c-harvey d512a1d329 Merge p12-w4-pullquote into dev (widen desktop pull-quote, fix 960px snap) 2026-06-17 22:10:18 -04:00
daniel-c-harvey 4e2033e40c fix(about): widen pull-quote desktop max-width 44ch to 70ch to end ribbon snap at 960px 2026-06-17 22:04:20 -04:00
daniel-c-harvey 8c811c411c docs: mark About photo slots largely resolved in COMPLETED.md
Bio portraits (Daniel + Khabran, circular/crossfade) and Process mixer
figure (dd-mixer-2) landed; dd-pedals now on Home Origin split. Khabran
bio text remains open.
2026-06-17 21:57:26 -04:00
daniel-c-harvey 44c17c8b73 Merge p12-w3-about-photos into dev (bio portraits, image swaps, circular framing, pull-quote width) 2026-06-17 21:52:25 -04:00
daniel-c-harvey d961eadc93 feature: Cleanup Waveform Controls 2026-06-17 21:51:29 -04:00
daniel-c-harvey c7d627b817 feat(about): wire bio portraits, swap images, circular frame, widen pull-quote
Portraits (1365 square) rendered as circles via border-radius:50%; parallax
tamed to fit. Process figure swapped to dd-mixer-2; Home Our Origin split
swapped to dd-pedals. Pull-quote widened 22ch to 44ch.
2026-06-17 21:45:55 -04:00
daniel-c-harvey 9850be8a49 docs: update About page to Liner Notes editorial treatment
CLAUDE.md: replace stale Home-primitives description with Liner Notes
layout and note about-rail.ts interop. COMPLETED.md: add redesign
addendum to Phase 12 About Page entry (Direction 1).
2026-06-17 20:12:12 -04:00
daniel-c-harvey f49e196596 Merge p12-w2-about-liner-notes into dev (About page Liner Notes editorial redesign) 2026-06-17 20:10:14 -04:00
daniel-c-harvey c8168564bb style(about): redesign /about as numbered "Liner Notes" editorial spine
Replace Home-cloned section grammar with a numbered left rail (Bodoni
numerals, vertical spine, mono marginalia), an asymmetric content column,
and SVG waveform dividers. Adds a degrade-safe IntersectionObserver interop.
Copy verbatim.
2026-06-17 20:04:00 -04:00
daniel-c-harvey a210b2ded7 docs(about): propose 3 visual-distinction directions
About reuses Home's section grammar in Home's order. New product note offers
three narrative-backbone directions (Liner Notes / Contact Sheet / Offset
Ledger) within brand guardrails, with a recommendation. Awaiting Daniel's pick.
2026-06-17 19:38:45 -04:00
daniel-c-harvey 7386ab0dd0 docs: reflect Phase 12 About Page landing
Move Phase 12 entry from PLAN.md to COMPLETED.md; note /about page and
open follow-ups (images, Khabran bio, shared primitives). Add terse
/about mention to CLAUDE.md public client bullet.
2026-06-17 18:20:06 -04:00
daniel-c-harvey 3f83e0f11c docs(phase-15): record polish round 2; mark slider decision superseded
Note the five round-2 changes in COMPLETED.md; mark §8 + the §2/§11 slider references superseded (scroll reverted to RadialKnob).
2026-06-17 18:19:32 -04:00
daniel-c-harvey 6303b4f62c Merge p12-w1-about-page into dev (About page in Home visual language) 2026-06-17 18:17:12 -04:00
daniel-c-harvey 02cc83ed31 Merge p15-w3-controls-polish into dev
Phase 15 polish round 2: mute panel ground, revert WAVE scroll to a RadialKnob,
add a distinct waveform glyph (DDIcons) for the waveform toggle, strong green
active-state on the toggles, and refresh the popover pointer-capture comment.
2026-06-17 18:15:33 -04:00
daniel-c-harvey a97cdcf395 fix(about): differentiate medium-card eyebrows; co-locate orphaned media query
Studio/Live/DJ Set eyebrows mirror Home's established vocabulary.
Orphaned @media (max-width: 960px) for .section-dark-standfirst
merged into the sibling dark-section block.
2026-06-17 18:13:00 -04:00
daniel-c-harvey 5614bbefad fix(DDIcons): correct Waveform doc-comment bar count from seven to six 2026-06-17 18:09:44 -04:00
daniel-c-harvey 6ecc7f1f37 polish(p15): mute panel, revert scroll to knob, waveform icon + strong toggle state
Mute --deepdrft-panel-ground; WAVE scroll MudSlider back to RadialKnob; new DDIcons Waveform/WaveformFilled glyph for the waveform toggle; strong green ON-state chip vs dim OFF; refresh popover pointer-capture comment.
2026-06-17 18:03:16 -04:00
daniel-c-harvey 35ae775954 feat(public): add /about page in Home visual language
Three-movement About page (People/Process/Product) built from Home's
section primitives; registered in nav. Image slots and Khabran's bio
degrade gracefully until assets/copy land.
2026-06-17 17:53:25 -04:00
daniel-c-harvey 412b96ba16 docs(about-page): lock spec as approved; final photos sole open item
Resolve §9 open questions: hero title "The Collective", Khabran bio as
empty-slot placeholder, wwwroot/img hosting, Process placement for
"designed not extracted". COPY D approved provisional; typo flags kept.
2026-06-17 17:46:37 -04:00
daniel-c-harvey 40b5cb8328 docs(about-page): apply Daniel's copy decisions
Mark A,B,C,E,D-intro,F,G,H approved with verbatim text; redraft D
(no Octave One, live-hardware spirit) pending approval; resolve the
medium-card question; flag two COPY C typos for confirmation.
2026-06-17 17:05:08 -04:00
daniel-c-harvey 7e27856359 docs: spec About page for public site (Phase 12)
Three-movement About page (People/Process/Product) in the Home page's
existing visual language; draft copy fenced for approval, image slots and
open questions captured. Adds product-notes/about-page.md and PLAN.md §12.
2026-06-17 16:30:56 -04:00
daniel-c-harvey 2c5c569797 docs(phase-15): record post-landing fixes + RCL TypeScript interop
Note the seven smoke-test fixes (incl. site-wide RadialKnob pointer capture) in
COMPLETED.md; document DeepDrftShared.Client TS interop in root CLAUDE.md.
2026-06-17 16:24:49 -04:00
daniel-c-harvey 855a4a5d2a Merge p15-w2-controls-fixes into dev
Phase 15 follow-up: fix seven control-panel + knob defects from Daniel's smoke
test — greyer panel ground, drag scrollbar + body-scroll lock, light caption
icons, centered WAVE slider, milder scrim, overlay above header/footer, and
real RadialKnob pointer capture (site-wide stuck-knob fix).
2026-06-17 15:55:42 -04:00
daniel-c-harvey 3835d9f9c4 fix(RadialKnob): real pointer capture via setPointerCapture interop
Switch initiator to @onpointerdown; capture the pointer on the knob element
through a new knob.ts helper so pointermove/up/cancel reach the knob even
when the cursor leaves the window. Accurate comment; IAsyncDisposable cleanup.
2026-06-17 15:43:26 -04:00
daniel-c-harvey 8a329aadcf fix(p15): remediate seven control-panel + knob defects
Greyer panel ground (token); remove drag scrollbar + lock body scroll; caption icons light; center WAVE slider; RadialKnob drag uses pointer events (robust to cursor leaving window); milder scrim alpha; overlay z-index above header/footer.
2026-06-17 15:32:01 -04:00
daniel-c-harvey e2c3f2a3aa docs: note eyebrow-label + divider-rule header on ReleaseDescription 2026-06-17 15:31:44 -04:00
daniel-c-harvey b16fc3ca7e Merge p16-w2-release-description-aesthetics into dev (editorial eyebrow + divider-rule styling for release blurb) 2026-06-17 15:30:41 -04:00
daniel-c-harvey 282cafc52f style(release-description): editorial eyebrow + divider-rule aesthetic 2026-06-17 15:30:33 -04:00
daniel-c-harvey 08f56d09d1 docs: note per-track Profile/High-res columns carry always-visible regenerate buttons 2026-06-17 15:23:00 -04:00
daniel-c-harvey e4b6fc525f fix: Release Description width 2026-06-17 15:22:30 -04:00
daniel-c-harvey 53a27ce06c Merge p16-w1-cms-grid-cleanup into dev (CMS grid cell layout fixes + per-track waveform regenerate buttons) 2026-06-17 15:15:35 -04:00
daniel-c-harvey fc32791cea fix(cms): fix grid cell vertical stacking; add per-track regenerate buttons
MixBrowser WaveformCell: wrap icon+button in MudStack Row. SessionBrowser
HeroCell: split into two SpecialActionColumns (thumb + button). AlbumBrowser
track table: always show regenerate button for Profile and High-res.
2026-06-17 15:15:23 -04:00
daniel-c-harvey 007033e7e8 docs: note ReleaseDescription blurb component on release detail pages 2026-06-17 14:57:27 -04:00
daniel-c-harvey e38678009e docs(phase-15): record visualizer controls landing
Move Phase 15 from PLAN to COMPLETED; fix DDIcons location to
DeepDrftShared.Client/Common; update WaveformVisualizerControls/Popover/State
descriptions for the three-row modal-overlay rework.
2026-06-17 14:50:30 -04:00
daniel-c-harvey 1fef60a7fb Merge release-description-blurb into dev (render release Description blurb on Session, Mix, and Cut detail pages) 2026-06-17 14:50:04 -04:00
daniel-c-harvey 29ab4840d0 Merge p15-w1-visualizer-controls into dev
Phase 15 — visualizer control-deck rework: screen-centered tinted MudOverlay
(NowPlayingCard chrome), deterministic three-row LAVA/WAVE layout, lava/waveform
lamp toggles backed by a genuine per-subsystem draw-skip, scroll/zoom slider,
playful tooltips, green=interactive/light=static colour principle.
2026-06-17 14:44:52 -04:00
daniel-c-harvey 15ddc4c332 feat: Styles 2026-06-17 14:44:08 -04:00
daniel-c-harvey 2c2342fbaf fix(p15): remediate four green-minor review findings
Tokenize scrim navy RGB triple (--deepdrft-scrim-rgb); LAVA row now
flex-start so knobs group left; WAVE row keeps space-between for
right-pinned width knob; remove inert margin-left:auto/wvc-row-right;
correct stale seven->ten count in OnControlStateChanged comment.
2026-06-17 14:42:23 -04:00
daniel-c-harvey b8f81edb59 feat: render release Description blurb on Session, Mix, and Cut detail pages
New shared ReleaseDescription control renders the blurb in a uniform themed block
below the hero/header; null/whitespace renders nothing, with no layout artifact.
2026-06-17 14:39:03 -04:00
daniel-c-harvey db8391b81c docs(phase-14): record /tracks→/releases consolidation
Update root CLAUDE.md DeepDrftManager description, log Phase 14 in
COMPLETED.md, and refresh the PLAN.md Phase 14 note.
2026-06-17 14:36:31 -04:00
daniel-c-harvey db29b0dd18 Merge p14-w1-releases-consolidation into dev (Phase 14: retire /tracks list, consolidate into /releases; catalogue cards → CUTS/SESSIONS/MIXES) 2026-06-17 14:28:37 -04:00
daniel-c-harvey dd4f8ddded feat(visualizer): Phase 15 control-deck rework
Centered tinted MudOverlay (NowPlayingCard chrome) replaces the anchored popover; eight dials become a deterministic three-row LAVA/WAVE layout; lava + waveform lamp toggles drive a genuine per-subsystem draw-skip; scroll/zoom becomes a slider; playful tooltips; green=interactive/light=static.
2026-06-17 14:28:15 -04:00
daniel-c-harvey 23a1275025 docs(Releases.razor): correct stale medium-tab comment — tabs are explicit markup, not enum-driven; adding a medium requires a hand-added panel in enum order 2026-06-17 14:27:50 -04:00
daniel-c-harvey 13fbcc2d43 fix: restore waveform status coherence, drop dead GetGenreSummaries, restore track info tooltip 2026-06-17 14:13:34 -04:00
daniel-c-harvey fe481d0417 docs(phase-15): resolve all five open questions
off = fully absent (real draw-skip seam); scroll/zoom binds ScrollSpeed;
labels light, lamp toggles green, mild tint from one token. Unify under
green = interactive, light = non-interactive.
2026-06-17 14:11:01 -04:00
daniel-c-harvey ded5dca698 docs: NowPlaying subscribes to player StateChanged to propagate live-track params 2026-06-17 14:09:07 -04:00
daniel-c-harvey 167b2fc3c5 Merge nowplaying-visualizer-coupling into dev (NowPlaying visualizer couples to live track when streaming starts) 2026-06-17 13:59:10 -04:00
daniel-c-harvey 2071a821db fix: NowPlaying re-renders on StateChanged so WaveformVisualizer gets live track params when streaming starts 2026-06-17 13:44:08 -04:00
daniel-c-harvey 6f00c6fa54 docs(phase-15): spec visualizer controls enhancements (modal popover, sectioned layout, lava/waveform toggles) 2026-06-17 13:44:00 -04:00
daniel-c-harvey 43bbc8172b docs: NowPlayingCard subscribes to player StateChanged 2026-06-17 13:37:47 -04:00
daniel-c-harvey 30999726e5 Consolidate CMS /tracks into standalone /releases page
Retire the Tracks list view; promote the Releases view to /releases with
medium tabs (ALL/CUTS/SESSIONS/MIXES). Migrate bulk profile/high-res
backfill and per-track waveform columns into the releases grids. Point
catalogue cards at the three mediums. Remove dead BrowseMode/ViewModel.
2026-06-17 13:35:25 -04:00
daniel-c-harvey 826ce218a4 Merge nowplaying-card-reactivity into dev (NowPlaying card now re-renders on track change) 2026-06-17 13:35:18 -04:00
daniel-c-harvey 739d6c6e81 Subscribe NowPlayingCard to player StateChanged so it re-renders on track change 2026-06-17 13:24:13 -04:00
daniel-c-harvey d12b732e40 docs(phase-12): record NowPlaying hero-background visualizer relocation 2026-06-17 13:17:08 -04:00
daniel-c-harvey e24048e961 Merge p12-w5-nowplaying-hero-bg into dev (Phase 12 cleanup: NowPlaying waveform visualizer becomes full-bleed hero-right background) 2026-06-17 13:14:27 -04:00
daniel-c-harvey 528f09d96a Move NowPlaying waveform visualizer to full-bleed hero-right background
Lift the WaveformVisualizer + control popover out of the 120px NowPlayingCard box into NowPlaying as a full-panel background layer; migrate the hero-right wrapper and its scoped styles from Home into NowPlaying.
2026-06-17 13:06:48 -04:00
daniel-c-harvey 0dce46bcab docs: record CMS public landing in root architecture (Phase 13)
DeepDrftManager bullet now describes the public splash at / and the
catalogue move to /catalogue. Also lands a stray Phase 12 DeepDrftAPI
waveform-vault doc edit left uncommitted by a concurrent session.
2026-06-17 12:40:48 -04:00
daniel-c-harvey f00758dc47 docs(phase-12): record waveform-visualizer generalization landing
Move the landed Phase 12 section from PLAN.md to COMPLETED.md; update DeepDrftAPI/Content/Public.Client CLAUDE.md for the WaveformVisualizer rename, per-track high-res datum + track-waveforms vault, track-cardinal fetch, popover controls, Ambient slot, and NowPlaying host.
2026-06-17 12:36:45 -04:00
daniel-c-harvey 8a187a3ed8 Merge p13-w1-cms-landing into dev (Phase 13: CMS public landing splash at /, catalogue moved to /catalogue) 2026-06-17 12:31:15 -04:00
daniel-c-harvey 9395f503b4 Merge p12-w4-t2-nowplaying into dev (12.D: real waveform visualizer in NowPlaying card, mode C + Fill mode) 2026-06-17 12:23:43 -04:00
daniel-c-harvey bc804afb55 Merge p12-w4-t1-ambient-slot into dev (12.C: ambient visualizer slot on scaffold + popover controls on all detail hosts) 2026-06-17 12:23:34 -04:00
daniel-c-harvey 80220d06f0 feat(cms): add public landing splash at /, move catalogue to /catalogue 2026-06-17 12:17:18 -04:00
daniel-c-harvey 05486a61af feat(now-playing): mount real waveform visualizer in NowPlaying card (mode C) + Fill container-sizing mode
Replace the 20 synthetic bars with a contained WaveformVisualizer driven by the live player, pointed at the current track; add a Fill mode (CSS-only, defaults off) sizing the canvas to its container; place the lava-lamp icon to popover on the card.
2026-06-17 12:15:49 -04:00
daniel-c-harvey 955182d6da feat(p12-w4): ambient visualizer slot on scaffold + popover controls on all detail hosts
Add optional Ambient slot to ReleaseDetailScaffold (full-bleed layer behind content; absent = no regression). Cut mounts it + popover; Session mounts the engine directly behind its hero; Mix swaps its inline knob-bar for the lava-lamp popover.
2026-06-17 12:11:03 -04:00
daniel-c-harvey 5fb46bf5eb docs(product): spec CMS public landing page (Phase 13)
Splash owns /, catalogue moves to /catalogue, authed users redirected
via HierarchicalRoleAuthorizeView. Skipper's public-layout pattern,
branded to DeepDrft. Adds Phase 13 to PLAN.md.
2026-06-17 11:44:33 -04:00
daniel-c-harvey 9009f2c8cf Merge p12-w3-bridge-live-track into dev (bridge follows the live playing track, not the fixed host TrackId) 2026-06-17 11:39:32 -04:00
daniel-c-harvey f1afe6e028 fix(visualizer): follow the live playing track, not the fixed host TrackId
Replace the TrackId-only IsActivePlayer gate with a LivePlayerTrack source that follows the playing track when it is the host track or shares the host release; single-track Mix/Session unchanged at parity.
2026-06-17 11:38:45 -04:00
daniel-c-harvey 7a3d44420a docs: document CMS upload heartbeat timeout and Upload:* tunables 2026-06-17 11:30:49 -04:00
daniel-c-harvey 4477026638 Merge cms-upload-heartbeat into dev (large CMS upload: idle/heartbeat timeout, two-phase response budget, per-file progress meter) 2026-06-17 11:27:55 -04:00
daniel-c-harvey 9f8808a596 Merge p12-w2-t2-popover-panel into dev (12.E: popover-hosted waveform control panel) 2026-06-17 11:22:36 -04:00
daniel-c-harvey b501cd9e3e Merge p12-w2-t1-track-fetch into dev (12.B2: track-cardinal high-res waveform fetch + bridge rewire) 2026-06-17 11:22:25 -04:00
daniel-c-harvey 803bc7840a fix(cms-upload): scope InfiniteTimeSpan to upload client; add response-wait budget after body completes 2026-06-17 11:14:15 -04:00
daniel-c-harvey 7aeced6a8d feat(visualizer): popover-hosted control panel (Phase 12.E)
Build WaveformVisualizerControlPopover pairing the lava-lamp trigger with the eight-knob WaveformVisualizerControls panel; style to the NowPlaying Hero look from tokens. Panel chrome scoped to the popover mount via a PanelChrome flag; Mix's inline mount unchanged.
2026-06-17 11:12:27 -04:00
daniel-c-harvey a19a734757 feat(p12-w2): track-cardinal high-res waveform fetch + bridge rewire
Add GET api/track/{trackEntryKey}/waveform/high-res (+ proxy), ITrackDataService.GetTrackWaveform; rewire visualizer to resolve the current track's EntryKey and re-fetch on track change. Retire the client mix-waveform read path.
2026-06-17 11:12:26 -04:00
daniel-c-harvey c9c6286571 Fix large CMS upload timeout with idle heartbeat and add per-file progress meter
Replace the 100s default HttpClient timeout (set Timeout=Infinite) with an idle/heartbeat
deadline driven by a ProgressStreamContent wrapper that reports bytes-on-the-wire. Each tick
resets the idle window and advances a MudProgressLinear per upload row. Idle window is
configurable via Upload:IdleTimeoutSeconds (default 90s).
2026-06-17 11:07:19 -04:00
daniel-c-harvey ec3989c354 Merge p12-w1-t2-highres-compute into dev (12.B1: generalize high-res waveform compute to every track, Direction B) 2026-06-17 10:29:30 -04:00
daniel-c-harvey 916bf626de Merge p12-w1-t1-rename into dev (12.A: rename Mix* visualizer engine to Waveform* abstraction) 2026-06-17 10:28:42 -04:00
daniel-c-harvey 3eef1a50f9 docs(release-controller): fix stale POST mix/waveform comment - track-waveforms vault, duration-derived high-res 2026-06-17 10:27:45 -04:00
daniel-c-harvey 585dd30efb fix(visualizer): correct cross-ref extension .ts to .cs in WaveformVisualizer comment 2026-06-17 10:27:42 -04:00
daniel-c-harvey accf20ba57 feat(waveform): generalize high-res compute to every track (Direction B)
Per-track high-res datum keyed by EntryKey in the renamed track-waveforms vault; computed at upload for all tracks, regenerable per-track via CMS, with a re-runnable backfill. Mix read path repointed so it keeps working.
2026-06-17 10:18:44 -04:00
daniel-c-harvey 3839948eeb refactor(12.A): rename Mix* visualizer engine to Waveform* abstraction 2026-06-17 10:16:44 -04:00
daniel-c-harvey dc70be768a feat: Archive Searchbar Padding
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m36s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-17 06:50:05 -04:00
daniel-c-harvey ad94354632 feat: Adjust RMS Window 2026-06-17 06:42:21 -04:00
daniel-c-harvey 8331ccf6a3 Merge fix-share-embed into dev (anchor SharePopover via documented MudPopover RelativeWidth; restore track-mode embed affordance) 2026-06-17 06:42:10 -04:00
daniel-c-harvey 372e006be1 docs(phase-10): update smoothing reference to ~15 ms 2026-06-17 06:39:10 -04:00
daniel-c-harvey dcfb1fca9f Merge p10-reduce-smoothing into dev (waveform smoothing 50ms->15ms; DEBUG flags off) 2026-06-17 06:33:43 -04:00
daniel-c-harvey ea74aaaf2e fix(mix): reduce waveform smoothing to 15 ms; turn off DEBUG flags 2026-06-17 06:33:03 -04:00
daniel-c-harvey 54ef4c038e doc: MixVisualizerControls 2026-06-17 06:31:06 -04:00
daniel-c-harvey 394b07f404 fix(share): anchor SharePopover via documented MudPopover RelativeWidth
Use Fixed + RelativeWidth=Adaptive with BottomLeft/TopLeft origins to
anchor the menu under the share button; drop the inline-block shrink-wrap
container hack. Keep AutoClose off so the embed panel survives clicks.
2026-06-17 06:30:31 -04:00
daniel-c-harvey d6df0de63a docs(phase-12): fold popover-hosted controls into spec + plan
Controls move from an inline per-page knob bar to a single popover-hosted
panel triggered by the lava-lamp icon, placed identically on every host
(Mix, Cut, Session, NowPlaying card). Dissolves the NowPlaying-controls
question — full parity via the popover. Adds the popover panel wave, panel
styling from theme tokens, and a popover-anchor open item.
2026-06-17 06:07:49 -04:00
daniel-c-harvey 76060f60a8 docs(phase-10): record visualizer tuning landing — eight-knob controls, server-only smoothing, spec shipped 2026-06-17 06:04:01 -04:00
daniel-c-harvey b9f06bb7cd Merge p10-remove-ts-smoothing into dev (drop client-side datum smoothing; waveform smoothing stays the server's job) 2026-06-17 05:47:16 -04:00
daniel-c-harvey d105385006 style(releases): scope gallery container with ::deep, move card radius to cover, narrow mix hero to 480px 2026-06-17 05:46:40 -04:00
daniel-c-harvey e48baa5b27 refactor(mix-visualizer): remove client-side datum smoothing — waveform smoothing is the server's job 2026-06-17 05:38:56 -04:00
daniel-c-harvey cf47fee07e docs(phase-12): revise spec — Direction B, per-track datum, full-parity controls
Daniel resolved the open questions: high-res compute for all media (B); the
waveform datum is per-track, not per-release (release is just the host —
dissolves the multi-track-Cut question); full-parity lava controls on all
detail hosts. Splits 12.B into compute+backfill / fetch+bridge; renames the
scaffold slot to Ambient. NowPlaying-card controls left as open sub-question.
2026-06-17 05:33:34 -04:00
daniel-c-harvey a0b3255028 Merge p10-visualizer-tuning into dev (smooth waveform, bouncy wax↔waveform collision, 8-knob controls with fluid amount/viscosity split, Visible-gated knob band) 2026-06-17 05:23:18 -04:00
daniel-c-harvey d36aea212c docs(visualizer): fix five inaccurate comments — sub-unity restitution, uniform heat boost, progressive push-out, scroll-speed cross-ref, eight-knob bar 2026-06-17 05:20:12 -04:00
daniel-c-harvey efef23753b docs(phase-12): spec waveform-visualizer generalization + NowPlayingHero rewire
Generalize the Mix-only WebGL lava visualizer into one release-cardinal
WaveformVisualizer serving Mix detail, all Release Detail pages, and the
home NowPlaying card. Four waves; flags the non-Mix datum-resolution call.
2026-06-17 05:12:19 -04:00
daniel-c-harvey 4e34696719 feat(mix-visualizer): Phase 10 tuning — smooth waveform, bouncy collision, 8 knobs
Smooth the loudness contour (~50 ms envelope at preprocessing + decode-time, plus
smootherstep render reconstruction); retune wax↔waveform collision to bouncy/sub-unity
(no explosion/stuck/jitter); split the bubbles knob into fluid-amount + fluid-viscosity
(cohesion via uniform-only smin/wobble); retune scroll/gravity/heat/width ranges; make
the colour rotation visible and boost OKLab chroma; the controls bar now holds its
layout and hides only its knobs via a Visible parameter.
2026-06-17 05:12:15 -04:00
daniel-c-harvey ba1a1cd8ec Merge fix-sharepopover-anchor into dev (anchor release SharePopover under the share button via inline-block wrapper) 2026-06-17 04:11:30 -04:00
daniel-c-harvey bfdbf7568f fix: Mix Visualizer Controls Styles 2026-06-17 04:08:18 -04:00
daniel-c-harvey 4eba3b0bb3 docs(SharePopover): correct inline-block/relative comments — inline-block is load-bearing, relative is incidental 2026-06-16 21:13:06 -04:00
daniel-c-harvey 39fabc8d0d docs(phase-10): record Mix hero-overlay landing — ReleaseHeroOverlay in CLAUDE.md, PLAN→COMPLETED, spec marked shipped 2026-06-16 21:08:14 -04:00
daniel-c-harvey 371812b274 Merge p10-w1-mix-hero-overlay into dev (Mix detail: shared ReleaseHeroOverlay, cover-as-overlaid-600px-square hero, ShowHeader scaffold gate) 2026-06-16 20:54:51 -04:00
daniel-c-harvey a6d25344b4 feat(mix-detail): extract shared ReleaseHeroOverlay; Mix cover becomes overlaid 600px square hero (Direction B) 2026-06-16 20:53:25 -04:00
daniel-c-harvey 81ea5909d2 fix(share-popover): anchor popover to button via relative-positioned wrapper div 2026-06-16 20:49:58 -04:00
daniel-c-harvey 9cf6bb4cf2 docs(phase-10): spec Mix detail hero+meta overlay mirroring Sessions (shared ReleaseHeroOverlay recommended) 2026-06-16 20:34:13 -04:00
daniel-c-harvey 3e97e34aee Merge p10-controls-if-guard into dev (Blazor @if-gated knob band, no CSS hide/glass/animation) 2026-06-16 20:31:56 -04:00
daniel-c-harvey fc7c9e978f feat(mix-visualizer): gate knob controls with Blazor @if in TopContent band; drop CSS collapse, glass, and TopRowCenter slot 2026-06-16 20:31:42 -04:00
daniel-c-harvey daafae8af6 Merge p10-controls-inline into dev (Phase 10 reframe: in-flow controls container between back link and lava-lamp, TopRowCenter slot) 2026-06-16 20:15:38 -04:00
daniel-c-harvey 841822d8fe fix(mix-visualizer): move seven-knob controls in-flow between back link and lava lamp (Phase 10 reframe §7b) 2026-06-16 20:12:02 -04:00
daniel-c-harvey 1730aa0166 docs(public): document StatusCodePages middleware ordering constraint 2026-06-16 20:07:43 -04:00
daniel-c-harvey b7a60f24c5 docs(phase-10): respec Mix visualizer controls as in-flow container between back link and lava-lamp 2026-06-16 20:05:59 -04:00
daniel-c-harvey f7366b167c Merge fix-antiforgery-statuscodepages into dev (share-link 404 fix: session/mix release-mode share + /404 antiforgery ordering) 2026-06-16 20:01:36 -04:00
daniel-c-harvey c926937694 fix(share): correct share URLs for session/mix detail pages — release mode + /tracks/ plural 2026-06-16 18:58:32 -04:00
daniel-c-harvey d8d908d4a6 fix(public): move UseStatusCodePagesWithReExecute before UseAntiforgery to fix 404 re-execution antiforgery error 2026-06-16 18:43:56 -04:00
daniel-c-harvey b5fdb826b0 Merge p11-cleanup-residuals into dev (P11 residual cleanup: stale-id docs, test EntryKey shapes, dead CSS) 2026-06-16 18:19:38 -04:00
daniel-c-harvey dae8020a22 chore(p11): fix stale-id docs, align test EntryKey shapes, drop dead track-card-link CSS 2026-06-16 18:05:37 -04:00
daniel-c-harvey 919a800f4b Merge p10-reframe-w3-color into dev (Phase 10 Reframe W3: OKLab three-color gradient + live density-size) 2026-06-16 18:03:46 -04:00
daniel-c-harvey 79de2503c4 feat(visualizer): OKLab three-color gradient + live density-size dial (Phase 10 reframe R3) 2026-06-16 18:03:20 -04:00
daniel-c-harvey 5b3036ed83 docs: record 11.H landed — Phase 11 complete (11.A-11.H); two release migrations pending apply 2026-06-16 17:44:52 -04:00
daniel-c-harvey 946b1d7cf9 Merge p11-w5-release-entrykey into dev (P11 11.H: release EntryKey on the public addressing surface; migration authored, not applied) 2026-06-16 17:26:53 -04:00
daniel-c-harvey 56d94b7424 Merge p10-reframe-w4-controls into dev (Phase 10 Reframe W4: 7-knob inline controls, always-on lava loop, filled icon) 2026-06-16 17:18:44 -04:00
daniel-c-harvey 41ac7a5a93 Phase 10 reframe R4: seven-knob inline visualizer controls, always-on lava loop, filled lava-lamp icon 2026-06-16 17:17:14 -04:00
daniel-c-harvey f07d29cdcf feat(release): front int PK with app-minted GUID EntryKey on the public addressing surface (P11 W5, 11.H) 2026-06-16 17:11:55 -04:00
daniel-c-harvey bb4e169d0a docs: record 11.D (Archive URL filters + GenresView repoint) landed (P11 W4) 2026-06-16 13:11:52 -04:00
daniel-c-harvey fe28573b68 chore(assets): track lava-lamp source SVG (glyph source for the visualizer-controls icon) 2026-06-16 13:08:15 -04:00
daniel-c-harvey d5ea5f52ee Merge p11-w4-archive-url-filters into dev (P11 11.D: Archive filters in URL, GenresView repoint) 2026-06-16 12:56:20 -04:00
daniel-c-harvey 78df665480 Merge p10-reframe-w2-tune into dev (Phase 10 Reframe W2 tuning: flat coalescing fluid, up+out elastic throw, heat turbulence, waveform-width) 2026-06-16 12:48:32 -04:00
daniel-c-harvey a64a5598ae feat(visualizer): R2 lava tuning — flat fluid, melt, up+out throw, heat-driven turbulence, waveform-width knob 2026-06-16 12:48:17 -04:00
daniel-c-harvey 5fb7d85019 docs(phase-10-reframe): fold Wave R2 eval into lava spec + PLAN (7th control, flat coalescing fluid, up-and-out collision) 2026-06-16 12:47:44 -04:00
daniel-c-harvey ca5fc5649a feat(archive): bind search/medium/genre filters to the URL (11.D); repoint genre tiles to /archive 2026-06-16 12:39:40 -04:00
daniel-c-harvey 09309630cb Merge p10-reframe-w2-physics into dev (Phase 10 Reframe W2: CPU wax-blob lava physics + 2D collision) 2026-06-16 12:19:39 -04:00
daniel-c-harvey db7afe4ea7 feat(p10-reframe-w2): CPU wax-blob lava physics + 2D collision; smin metaball render 2026-06-16 12:19:30 -04:00
daniel-c-harvey e6a80b6086 docs(plan): lock P11 11.H decision — additive EntryKey string, track-pattern, migration-time backfill 2026-06-16 12:19:25 -04:00
daniel-c-harvey f35cbc82fe docs: record 11.C (retire+normalize) and 11.E (release Share) landed (P11 W3) 2026-06-16 12:03:01 -04:00
daniel-c-harvey ed7304af1f Merge p11-w3-t2-release-share into dev (P11 11.E: release-keyed SharePopover mode, Cut header Share) 2026-06-16 11:58:20 -04:00
daniel-c-harvey 0b2fee1520 Merge p11-w3-t1-retire-normalize into dev (P11 11.C: retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery) 2026-06-16 11:58:06 -04:00
daniel-c-harvey cff18df783 Merge p10-reframe-w1-fix into dev (minimized-footer clip + lava-lamp SVG glyph) 2026-06-16 11:57:35 -04:00
daniel-c-harvey 2c4bd3a394 fix(p10-reframe-w1): clip visualizer to minimized FAB height; replace LavaLamp icon with SVG Repo glyph 2026-06-16 11:53:47 -04:00
daniel-c-harvey d899bc9456 docs(plan): add Phase 11 commitment 9 (release GUID identifiers, wave 11.H) 2026-06-16 11:43:11 -04:00
daniel-c-harvey ce437521ee feat(share): add release-keyed copy-link mode to SharePopover; wire Cut header (§3b, P11 W3 11.E) 2026-06-16 11:31:03 -04:00
daniel-c-harvey ef6d21b94e refactor(public): retire track-cardinal stack, fold Archive/Cuts cards into ReleaseGallery (P11 W3 §4) 2026-06-16 11:31:02 -04:00
daniel-c-harvey bef1e3adfb docs: record 11.B ReleaseRoutes resolver landed (P11 W2) 2026-06-16 11:18:16 -04:00
daniel-c-harvey 313551ac7c Merge p10-reframe-w1-clean into dev (Phase 10 Reframe W1: de-noise, dynamic footer clip, lava-lamp icon redraw) 2026-06-16 11:17:32 -04:00
daniel-c-harvey f08b412772 docs(product): fold Mix Visualizer lava reframe under Phase 10 (Waves R1-R4); inline knob-bar + icon redraw 2026-06-16 11:16:03 -04:00
daniel-c-harvey d98ead97c3 Merge p11-w2-releaseroutes-resolver into dev (P11 11.B: ReleaseRoutes.DetailHref resolver + repoint, /tracks/{id} redirect) 2026-06-16 11:13:22 -04:00
daniel-c-harvey ff37efea89 Phase 10 W1: de-noise Mix visualizer, clip to live player-bar height, redraw lava-lamp icon 2026-06-16 11:12:20 -04:00
daniel-c-harvey 55515981a9 feat(routing): add ReleaseRoutes.DetailHref resolver; repoint release click sites and add /tracks/{id} redirect (P11 W2 §2) 2026-06-16 10:56:28 -04:00
daniel-c-harvey 74b9c02722 docs(plan): add Phase 12 Mix Visualizer Lava Reframe spec; supersede Phase 10 effects/controls 2026-06-16 10:33:24 -04:00
daniel-c-harvey 96b13af95d docs: record §3.4 PlayAlbum queue seam closure (P11 W1 follow-up)
CutDetail Play affordances now consume IQueueService.PlayRelease; annotate
PLAN.md §11 landed note and add COMPLETED.md entry.
2026-06-16 10:28:19 -04:00
daniel-c-harvey f8f9844ef4 Merge p11-w1-playalbum-seam into dev (P11 W1: wire CutDetail Play to IQueueService, §3.4 seam closed) 2026-06-16 10:26:57 -04:00
daniel-c-harvey 6ac943ca09 feat(cuts): wire PlayAlbum/PlayTrack to IQueueService.PlayRelease (§3.4 seam, P11 W1)
Header Play loads full album at index 0; row play loads at that row's index with same-track
toggle preserved; null-safe cascade fallback to direct SelectTrackStreaming when queue absent.
2026-06-16 10:22:59 -04:00
daniel-c-harvey 364450885b Merge p10-w4-popover-knobs into dev (Phase 10 Wave 4: lava-lamp popover, RadialKnob controls, wider Mix detail body) 2026-06-16 00:48:12 -04:00
daniel-c-harvey fbb397228e Merge dev into p10-w4-popover-knobs (integrate concurrent Phase 11 scaffold changes)
# Conflicts:
#	DeepDrftPublic.Client/Controls/ReleaseDetailScaffold.razor.cs
2026-06-16 00:47:58 -04:00
daniel-c-harvey c2a3e53991 Merge p10-w3-effects-rework into dev (P10 W3 rework: vivid HSL field, time-driven bubbling, surface-born bubbles, working color-shift) 2026-06-16 00:40:41 -04:00
daniel-c-harvey 23b34004ff Merge p11-w1-description-schema into dev (P11 11.G: release Description field, migration authored) 2026-06-16 00:38:13 -04:00
daniel-c-harvey aedbe82d28 Merge p11-w1-queue-service into dev (P11 11.F: play-queue IQueueService + skip controls) 2026-06-16 00:37:31 -04:00
daniel-c-harvey 2bb7d86e63 fix(icons): strip outer <svg> wrapper from LavaLamp — MudBlazor supplies its own 2026-06-16 00:37:20 -04:00
daniel-c-harvey ff9c87c461 Merge p11-w1-cuts-detail into dev (P11 11.A: /cuts/{id} album-detail page) 2026-06-16 00:37:01 -04:00
daniel-c-harvey e59271aa00 feat(mix): lava-lamp popover with RadialKnob controls + wider Mix detail body (P10 W4) 2026-06-16 00:19:47 -04:00
daniel-c-harvey b27ec1b7d0 docs: record Phase 11 Wave 1 landed (11.A cuts page, 11.F queue, 11.G description)
Annotate PLAN.md §11 with landed tracks; add COMPLETED.md Phase 11 section;
document TrackNumber as a supported sortColumn in DeepDrftAPI/CLAUDE.md.
2026-06-16 00:19:41 -04:00
daniel-c-harvey c1ed2a9ba3 fix(visualizer): vivid HSL field, time-driven bubbling, surface-born bubbles, visible color-shift (P10 W3 rework) 2026-06-16 00:16:37 -04:00
daniel-c-harvey 294414d00a fix(queue): guard OnTrackEnded against direct-play cross-context advance
Only advance when player's CurrentTrack.Id matches queue's Current.Id;
direct-play call sites (SessionDetail, StreamNowButton, resume) that
supersede the queue no longer spuriously advance the album. Adds
regression test covering the scenario.
2026-06-16 00:13:51 -04:00
daniel-c-harvey 2b42e01cd0 feat(player): add IQueueService orchestrating album playback above the single-slot player (P11 11.F)
Queue owns ordered tracks, current index, skip-fwd/back, and auto-advance via the player's TrackEnded hook; binds through Attach (no ctor growth, no service-locator). Player-bar skip controls; empty-queue play unchanged. Adds QueueService unit tests.
2026-06-16 00:04:44 -04:00
daniel-c-harvey 26d7a05ba4 docs: record Phase 10 Wave 3 (in-shader effects) landed 2026-06-16 00:01:05 -04:00
daniel-c-harvey cfacc9f79a feat(release): add plain-text Description field plumbed CMS->DTO->release (11.G)
New nullable Description column (max 4000) on ReleaseEntity, rides the Genre write channel through upload + edit; multiline CMS input. Migration authored, not applied.
2026-06-16 00:00:06 -04:00
daniel-c-harvey 07ddc69cee feat(public): add /cuts/{id} album-detail page
Compose ReleaseDetailScaffold via Header + BodyContent slots for the Cut
album view: left meta + Play/Share, right theme-bordered cover, TrackNumber-
ordered track list with per-row play. CutDetailBase carries the multi-track
prerender bridge.
2026-06-15 23:59:19 -04:00
daniel-c-harvey 779e1f569c Merge p10-w3-shader-effects into dev (Phase 10 Wave 3: four in-shader effects — gradient field, bubblyness, lava-lamp detach, glass) 2026-06-15 23:56:07 -04:00
daniel-c-harvey 5011fb43f0 perf(shader): hoist playhead texture tap; clamp neighbour sdRoundBox corner radius 2026-06-15 23:55:16 -04:00
daniel-c-harvey a9d6445881 feat(visualizer): four in-shader Mix effects — morphing navy-moss field, bubblyness, lava-lamp detach, glass (P10 W3) 2026-06-15 23:42:44 -04:00
daniel-c-harvey 56e205082d docs(plan): add release Description field as commitment 8 / wave 11.G
Verified no Description column exists on ReleaseEntity/ReleaseDto (mirror
image of commitment 5, which was already built). Specs the new base-release
column + EF migration (Daniel-gated), DTO/converter/write-path plumbing,
CMS multiline input, and detail-page text block. Schema lands as 11.G;
render rides 11.A plus a Session/Mix touch.
2026-06-15 23:38:51 -04:00
daniel-c-harvey 31e00e6abd docs(plan): spec Phase 10 Wave 4 — Mix detail popover controls, RadialKnobs, lava-lamp icon, wider body 2026-06-15 23:38:26 -04:00
daniel-c-harvey e9f4411fdf docs(plan): revise Phase 11 — ordinal, full stack retirement, shared cards, release-share, queue
Fold Daniel's 2026-06-15 decisions into PLAN.md §11 and the product note:
4→7 commitments, six waves. Headline: the track ordinal already shipped
in Phase 8, so commitment 5 is verify-and-consume, not a new migration.
Queue half of §1.3 absorbed; preload stays deferred.
2026-06-15 23:30:28 -04:00
daniel-c-harvey 22c2ae5ecb chore: RadialKnob control 2026-06-15 23:26:11 -04:00
daniel-c-harvey b7bd6ba04f docs: record Phase 10 Wave 2 (controls row + control state) landed 2026-06-15 23:22:02 -04:00
daniel-c-harvey 1e6129401b Merge p10-w2-controls-row into dev (Phase 10 Wave 2: visualizer controls row + unified MixVisualizerControlState) 2026-06-15 23:20:28 -04:00
daniel-c-harvey bf00b7f22f feat(visualizer): controls row + unified MixVisualizerControlState; 3 inert uniforms wired (P10 W2) 2026-06-15 23:15:44 -04:00
daniel-c-harvey 913861860b docs(plan): shape Phase 11 — Public Site Enhancements
Add Phase 11 to PLAN.md and a full design spec under product-notes:
Cuts gain a /cuts/{id} album detail page; release-title click resolves
medium to a dedicated detail page; redundant /tracks?album view retired;
Archive filters move into the URL. Includes gap analysis and open
questions for Daniel.
2026-06-15 23:09:16 -04:00
daniel-c-harvey e0f371cda6 Merge p10-w1-jitter-cleanup into dev (P10 W1: startup-jitter easing, diagnostics gated off, review remediation) 2026-06-15 22:46:46 -04:00
daniel-c-harvey 44a15bf67d fix(review): const→static readonly Debug silences CS0162; update stale rAF comment to renderedPlayhead() 2026-06-15 22:44:20 -04:00
daniel-c-harvey 65e5e09245 fix(visualizer): ease playhead re-anchor to kill startup jitter; gate diagnostics off (P10 W1) 2026-06-15 22:32:02 -04:00
daniel-c-harvey d73e94a12f Merge p10-w1-fps-smoothness into dev (P10 W1: wall-clock playhead interpolation for smooth 60 FPS scroll) 2026-06-15 22:16:52 -04:00
daniel-c-harvey df4381b4d8 fix(visualizer): interpolate Mix playhead on wall clock so ribbon scrolls at 60 FPS, not 10 Hz push cadence 2026-06-15 22:16:45 -04:00
daniel-c-harvey ad8cb7dbc0 Merge p10-w1-resolution-slider into dev (P10 W1: fix zoom slider stacking-context occlusion) 2026-06-15 21:54:43 -04:00
daniel-c-harvey 652c90979d fix(visualizer): lift zoom slider out of fixed backdrop's stacking context so it receives pointer events again (P10 W1) 2026-06-15 21:54:22 -04:00
daniel-c-harvey 1ad501ff11 Merge mix-play-track-selection-fix into dev (forward releaseId through TrackProxyController so WASM-path mix/session Play resolves the release's own track) 2026-06-15 21:23:35 -04:00
daniel-c-harvey c9b8dfcf3f fix(proxy): forward releaseId filter in TrackProxyController.GetPage so WASM-path mix/session track resolution is not stripped at the proxy boundary 2026-06-15 21:07:50 -04:00
daniel-c-harvey 2bacf58241 Merge session-detail-hero-overlay into dev (Session detail hero-overlay redesign, NowPlaying-themed) 2026-06-15 20:38:09 -04:00
daniel-c-harvey 83c0425133 docs: note SessionDetail hero-overlay composition and scaffold divergence 2026-06-15 19:35:36 -04:00
daniel-c-harvey 0758bfe7f1 fix(css): add ::deep to .session-detail-page so Blazor isolation scope pierces MudContainer boundary 2026-06-15 19:32:34 -04:00
daniel-c-harvey 79e7bb4799 Merge p10-w1-renderer-fix2 into dev (P10 W1: 2-D datum texture fixes GL_MAX_TEXTURE_SIZE overflow + bridge diagnostics) 2026-06-15 19:29:11 -04:00
daniel-c-harvey 45bf5e5d37 fix(visualizer): lay Mix datum across a 2-D R8 texture to respect GL_MAX_TEXTURE_SIZE; manual texelFetch lerp avoids row-wrap seam 2026-06-15 19:28:52 -04:00
daniel-c-harvey 3c7f28b2eb redesign(public): session detail as hero-overlay composition, NowPlaying-themed 2026-06-15 19:27:12 -04:00
daniel-c-harvey 61d53dacff Merge p10-w1-renderer-fix into dev (P10 W1: blank ribbon at rest + WebGL init/draw diagnostics) 2026-06-15 17:45:50 -04:00
daniel-c-harvey 06b58304c5 fix(visualizer): blank Mix ribbon at rest + init/draw diagnostics (P10 W1) 2026-06-15 17:45:21 -04:00
daniel-c-harvey b3283d0bd2 docs: record Phase 10 Wave 1 (WebGL2 renderer swap) landed 2026-06-15 13:43:19 -04:00
daniel-c-harvey cb6f75be5f Merge p10-w1-renderer-swap into dev (Phase 10 Wave 1: WebGL2 fragment-shader Mix renderer at parity) 2026-06-15 13:36:01 -04:00
daniel-c-harvey c1562dde03 Merge track-detail-play-wrong-track into dev (fix wrong-track Play on detail pages) 2026-06-15 13:34:09 -04:00
daniel-c-harvey 8b0bd6d26e docs(client): note detail pages must load in OnParametersSetAsync under InteractiveAuto 2026-06-15 13:15:04 -04:00
daniel-c-harvey 7d23c0654b fix(detail): capture guard fields before await to close re-entrancy window in OnParametersSetAsync 2026-06-15 12:55:15 -04:00
daniel-c-harvey cab181db4b refactor(visualizer): remove dead sampleCount field from Datum — shader uses durationSeconds only 2026-06-15 12:49:47 -04:00
daniel-c-harvey f02f370ed9 fix(detail): reload track on route-param change so Play uses the right track
Detail pages loaded only in OnInitialized, which doesn't re-run when an
InteractiveAuto component instance is reused across same-template navigations,
leaving a stale track that Play streamed. Move load to OnParametersSetAsync
keyed on the route id, and guard the prerender bridge restore against an id mismatch.
2026-06-15 12:47:57 -04:00
daniel-c-harvey b451dda79e feat(visualizer): WebGL2 fragment-shader Mix renderer at parity; datum-as-texture, shader-clock rAF, drop CSS backdrop-filter (P10 W1) 2026-06-15 12:43:56 -04:00
daniel-c-harvey 4f84216ab6 Merge cms-special-action-columns into dev
Promote CMS release special actions (Mix waveform, Session hero) to dedicated grid columns.
2026-06-15 12:01:17 -04:00
daniel-c-harvey bb50d8369b Merge ui-detail-cover-art into dev (medium cover thumbnails on mix and session detail pages) 2026-06-15 11:59:39 -04:00
daniel-c-harvey ebdcc29f2e docs(mix-visualizer): lock MixVisualizerControlState widen decision (§3c) 2026-06-15 11:57:47 -04:00
daniel-c-harvey ea8b97e47b docs: spec WebGL2 Mix visualizer renderer (Phase 10)
Replaces the 1-2 FPS Canvas 2D visualizer with a WebGL2 fragment-shader
renderer. Four-control row, morphing navy/moss field, in-shader glass.
Full spec in product-notes; PLAN.md Phase 10 points at it.
2026-06-15 11:36:46 -04:00
daniel-c-harvey f1600023dc feat(detail): medium release cover thumbnails on mix and session detail pages 2026-06-15 11:36:39 -04:00
daniel-c-harvey 09f6dc88f7 docs: record CmsAlbumBrowser special-action column promotion 2026-06-15 11:30:54 -04:00
daniel-c-harvey 31084b09a4 fix(cms): stabilize _specialColumns allocation and refresh stale comments
Allocate _specialColumns once in OnInitialized; update RowActions references to SpecialColumns in the medium browsers and base class.
2026-06-15 11:26:21 -04:00
daniel-c-harvey 5941f1f23a feat(cms): dedicated grid columns for medium-specific row actions
Replace CmsAlbumBrowser's single RowActions slot with a SpecialColumns
list (header + per-row cell). Mix renders a Waveform column, Session a
Hero column, between Tracks and Actions; Edit/Delete stay rightmost.
Child-row colspan now computed from column count. Cut/ALL unchanged.
2026-06-15 11:16:15 -04:00
daniel-c-harvey 7f1c6bdb66 Merge ui-share-relocate into dev (share button centered below detail metadata) 2026-06-15 11:13:22 -04:00
daniel-c-harvey 37608aee28 Merge ui-mix-visualizer into dev (footer above waveform backdrop; zoom slider to top) 2026-06-15 11:13:05 -04:00
daniel-c-harvey e0ab2f3d00 Merge ui-archive-search into dev (archive search controls: centered flex row + narrow reflow) 2026-06-15 11:12:58 -04:00
daniel-c-harvey 41e3ccc9fa fix(archive): center medium toggle between balanced search/genre side zones 2026-06-15 11:10:06 -04:00
daniel-c-harvey 709103ad71 ui: move SharePopover below metadata on all release detail pages 2026-06-15 10:45:27 -04:00
daniel-c-harvey 9f074f7350 fix(visualizer): lift footer above waveform backdrop; move zoom slider to top-right 2026-06-15 10:45:25 -04:00
daniel-c-harvey 47082591ee refactor(archive): single flex row for search/medium/genre controls with narrow-screen reflow 2026-06-15 10:45:18 -04:00
daniel-c-harvey 4df2b8fb57 Merge 8k-w2-renderer into dev (8.K Wave 2: scrolling Canvas 2D Mix visualizer, read-only) 2026-06-14 19:02:16 -04:00
daniel-c-harvey a9965ad751 docs: record 8.K Mix Visualizer redesign landed; Wave 8 fully complete 2026-06-14 18:39:19 -04:00
daniel-c-harvey c64455f2f2 fix(visualizer): gate rAF loop on is-playing; one-shot redraws while idle (§E) 2026-06-14 18:31:24 -04:00
daniel-c-harvey 2d0a565765 feat(public): scrolling Canvas 2D Mix visualizer — windowed, playback-coupled, zoomable, read-only (8.K W2) 2026-06-14 18:20:32 -04:00
daniel-c-harvey c608fa345a Merge 8k-w1-datum into dev (8.K Wave 1: duration-derived Mix waveform datum density) 2026-06-14 17:13:05 -04:00
daniel-c-harvey 09a980ba2a feat(api): derive Mix waveform datum density from duration (~333 samples/sec, capped/floored) instead of fixed 2048 buckets 2026-06-14 16:21:57 -04:00
daniel-c-harvey da08ac4efb Merge p9-w8-8m-legacy-form-retirement into dev (8.M: retire legacy single-track forms, track-addressed BatchEdit) 2026-06-14 14:51:47 -04:00
daniel-c-harvey 00d7215178 docs: record Wave 8 track 8.M landed (legacy single-track form retirement) 2026-06-14 12:43:42 -04:00
daniel-c-harvey 898fcfaa04 feat(cms): retire legacy single-track forms; route single-track edit into BatchEdit (8.M) 2026-06-14 11:53:06 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 4b9e6531fd docs: record Wave 8 track 8.A landed 2026-06-13 22:16:05 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 3f1230fd2d docs: record Wave 8 tracks 8.B and 8.I landed 2026-06-13 21:38:00 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 2991d9ec5d docs: record Wave 8 tracks 8.F and 8.H landed 2026-06-13 21:11:23 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 18f4b596f2 docs: record Wave 8 tracks 8.D/8.G/8.J/8.L landed 2026-06-13 20:18:04 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey 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-c-harvey fccace1381 docs: spec Phase 9 Wave 8 remediation + Mix Visualizer interview set 2026-06-13 17:02:53 -04:00
daniel-c-harvey 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-c-harvey 77a9eb1158 Merge p9-api-http-smokes into dev (Phase 9 API .http smoke file) 2026-06-13 16:12:21 -04:00
daniel-c-harvey f6b7fa2df5 feat: add Phase 9 API smoke tests (.http file) 2026-06-13 16:09:42 -04:00
daniel-c-harvey 2f565deb8f Merge p9-w7-cardinality-invariant into dev (9.7 per-medium cardinality invariant) 2026-06-13 15:27:33 -04:00
daniel-c-harvey 26246b5d65 docs: Phase 9 Wave 7 landed — move 9.7 from PLAN to COMPLETED 2026-06-13 14:28:02 -04:00
daniel-c-harvey 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-c-harvey 6f42464294 docs: Phase 9 Wave 6 landed — move 9.6 from PLAN to COMPLETED 2026-06-13 13:51:59 -04:00
daniel-c-harvey 6e0da7a486 Merge p9-w6-t2-batchedit-collapse into dev (9.6.B) 2026-06-13 13:23:49 -04:00
daniel-c-harvey 79c4e1e584 Merge p9-w6-t1-home-card-links into dev (9.6.A) 2026-06-13 13:23:35 -04:00
daniel-c-harvey 0371bcd15e docs: spec Phase 9 Wave 7 — per-medium track-cardinality domain invariant 2026-06-13 13:17:33 -04:00
daniel-c-harvey 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-c-harvey bcfcc91618 wire medium cards to routes: Studio->/cuts, Live->/sessions, DJ Mix->/mixes 2026-06-13 12:55:02 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 7f575d1d75 Merge p9-w5-gitattributes-eol into dev 2026-06-13 12:22:07 -04:00
daniel-c-harvey 9a9adf5a57 gitattributes: pin parallax.js to LF to stop CRLF working-tree churn 2026-06-13 12:20:34 -04:00
daniel-c-harvey 31d7b20672 Merge p9-w5-t3-browser-edit-dry into dev (9.5.E/F) 2026-06-13 11:46:30 -04:00
daniel-c-harvey 3ab1d77ecb Merge p9-w5-t2-tracks-nav into dev (9.5.D) 2026-06-13 11:46:23 -04:00
daniel-c-harvey 0b989aa739 Merge p9-w5-t1-medium-write-path into dev (9.5.A/B/C) 2026-06-13 11:46:17 -04:00
daniel-c-harvey bb61cf4014 docs: Phase 9 Wave 5 landed — move 9.5 from PLAN to COMPLETED 2026-06-13 11:41:50 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey a40d82fa22 nav: add Tracks entry to public MenuPages 2026-06-13 11:05:52 -04:00
daniel-c-harvey ea018beb3e docs: spec Phase 9 Wave 5 — gap cleanup 2026-06-13 08:44:42 -04:00
daniel-c-harvey 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-c-harvey 3ea4eb143b Merge branch 'p9-w4-public' into dev 2026-06-13 07:13:30 -04:00
daniel-c-harvey d4d28fdb0e Merge branch 'p9-w3-cms' into dev 2026-06-13 07:13:28 -04:00
daniel-c-harvey 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-c-harvey 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-c-harvey 5f7eaed112 docs: Phase 9 Wave 2 landed — move 9.2 from PLAN to COMPLETED 2026-06-12 22:26:28 -04:00
daniel-c-harvey 46749c8fa4 Merge branch 'p9-w2-api' into dev 2026-06-12 22:18:34 -04:00
daniel-c-harvey 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-c-harvey 22f4939b24 docs: move Phase 9 §9.1 from PLAN to COMPLETED 2026-06-12 21:53:45 -04:00
daniel-c-harvey 93dcc59814 Merge branch 'p9-w1-data-model' into dev 2026-06-12 21:48:56 -04:00
daniel-c-harvey 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-c-harvey 6f63fe7d7c docs: amend Phase 9 spec — apply SOLID review fixes F0-F13 2026-06-12 21:15:36 -04:00
daniel-c-harvey 8087fd04ce docs: SOLID review of Phase 9 spec — waveform compute tier flagged critical 2026-06-12 21:00:04 -04:00
daniel-c-harvey c1271aeb90 docs: resolve 3 Phase 9 open questions from Daniel
Genre browse stays route-reachable (deprioritized, not retired).
Session/Mix single-track is a hard upload constraint.
/albums redirects to /cuts when CUTS lands.
2026-06-12 17:39:30 -04:00
daniel-c-harvey 0b349da5f8 docs: spec Phase 9 — Release Medium Types
Four-wave plan for ReleaseMedium discriminator (Cut/Session/Mix),
medium-specific metadata tables, CMS Release Archive tab, and public
ARCHIVE nav + CUTS/SESSIONS/MIXES browse + detail surfaces.
2026-06-12 16:26:32 -04:00
daniel-c-harvey f07ad58655 Merge branch 'parallax-js-deploy-fix' into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m9s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m23s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m49s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-12 06:41:51 -04:00
daniel-c-harvey 2f7f8dbdf8 fix: track compiled RCL parallax JS for MapStaticAssets deployment
DeepDrftShared.Client's wwwroot/js/ was gitignored, so the TS-compiled
parallax.js was absent at build-time manifest generation. MapStaticAssets
serves _content/ exclusively from the build manifest, so the file was
missing from the publish output — requests fell through to the Blazor
HTML handler, producing a text/html MIME-type error in the browser.

DeepDrftPublic audio JS is unaffected because UseStaticFiles() serves
that startup project's physical wwwroot/ directly, bypassing the manifest.
The RCL has no such bypass, so its compiled JS must be present at
manifest-generation time, which requires tracking it in git.
2026-06-12 06:39:07 -04:00
daniel-c-harvey 528b904d72 Merge branch 'cms-bug-fixes' into dev 2026-06-12 06:33:24 -04:00
daniel-c-harvey 0448711082 fix: CMS image proxy + partial unique index for soft-deleted releases 2026-06-12 06:27:34 -04:00
daniel-c-harvey dd30d57838 Merge branch 'tsconfig-publish-fix' into dev
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m19s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m50s
Deploy DeepDrftManager / Deploy (push) Successful in 1m23s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m27s
2026-06-11 20:58:37 -04:00
daniel-c-harvey 70f110bed7 fix: use Content Update to suppress tsconfig.json from publish output 2026-06-11 20:55:43 -04:00
445 changed files with 54622 additions and 3570 deletions
+3
View File
@@ -7,3 +7,6 @@
*.yaml text eol=lf
*.service 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
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
+7 -1
View File
@@ -314,4 +314,10 @@ Database/Vaults/*
**/wwwroot/js/*
# ...except hand-authored client JS modules (not TS compile output).
!DeepDrftPublic.Client/wwwroot/js/
!DeepDrftPublic.Client/wwwroot/js/*.js
!DeepDrftPublic.Client/wwwroot/js/*.js
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
# gitignored TS output is absent when manifest is generated, so absent from publish output.
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
# any added later) ships, rather than maintaining a per-module allowlist.
!DeepDrftShared.Client/wwwroot/js/
!DeepDrftShared.Client/wwwroot/js/**
+42 -18
View File
@@ -8,12 +8,12 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Consumed by the public site.
- **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.
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), crawl-directive endpoints (`GET /robots.txt` and `GET /sitemap.xml`, environment-gated via `IWebHostEnvironment.IsProduction()` directly — server-side only, no PersistentState bridge — served by `CrawlDirectiveController` with pure builders in `Seo/RobotsTxt.cs` and `Seo/SitemapXml.cs`), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `<HeadContent>` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. **Always uncrawlable**: a static `wwwroot/robots.txt` (`Disallow: /`, no env gate) plus a blanket `<meta name="robots" content="noindex,nofollow">` in `Components/App.razor` — defense in depth so the CMS is never indexed regardless of how it is discovered. 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). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **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.
- **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 (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, 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.
- **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.
@@ -34,7 +34,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
1. **SQL Database (PostgreSQL)**: Metadata and track info via Entity Framework
- Connection string: Read from `environment/connections.json` via `CredentialTools.ResolvePathOrThrow("connections")` with key `ConnectionStrings:DefaultConnection`.
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `DurationSeconds?`
- Context: `DeepDrftContext` in `DeepDrftData`
2. **FileDatabase**: Custom file-based binary storage system
@@ -42,7 +42,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
- Root contains typed **MediaVaults** (Media, Image, Audio)
- Each vault has a JSON `index` file listing entries + per-entry metadata
- Entries are user-supplied strings sanitized to `[a-zA-Z0-9-]` + file extension
- Binary hierarchy: `FileBinary``MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio)
- Binary hierarchy (model shapes): `FileBinary``MediaBinary` (+ Extension/MIME) → `AudioBinary` (+ Duration/Bitrate) | `ImageBinary` (+ AspectRatio). **Non-delivery read path** (`LoadResourceAsync<AudioBinary>`) returns a full-buffer `AudioBinary` — still used for non-delivery operations (e.g., duration backfill). **Audio delivery path** streams via `GetEntryStreamAsync` (Opus artifact, `track-opus` vault) or `OpenAudioMediaStreamAsync` (lossless source, `tracks` vault) — a seekable, disk-backed `Stream` per request, never a whole-file `byte[]` (read-side OOM fix, parallel to the store-side). The **write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + streamed `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file then `File.Move` atomic-rename into place — the full `AudioBinary` buffer is never materialized on this path.
- **Error-handling philosophy**: public operations swallow exceptions and return `null`/`false` — callers must check return values, not catch.
## Key Architectural Decisions
@@ -57,34 +57,58 @@ The split between host projects (`DeepDrftPublic`, `DeepDrftManager`, `DeepDrftC
`TrackEntity` holds *only* metadata. The link to binary content is `EntryKey` (string) — the entry id inside the `tracks` vault in FileDatabase. Dual-database add flow:
1. `DeepDrftContent.TrackService.AddTrackFromWavAsync` processes WAV, generates entry GUID, stores audio in vault, returns unpersisted `TrackEntity`.
1. `TrackContentService.AddTrackAsync` routes the audio file by extension (`AudioProcessorRouter`), produces a `ProcessedAudio` plan (bounded-header metadata + streamed `WriteToAsync` callback — no whole-file `AudioBinary` buffer), and stores it in the vault via `FileDatabase.RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` (atomic temp→rename on the Linux host). Returns an unpersisted `TrackEntity` with `DurationSeconds` populated from the header parse. Wave 1 OOM fix.
2. `DeepDrftAPI.Services.UnifiedTrackService.UploadAsync` persists the entity to SQL via `DeepDrftData.TrackManager` and returns the persisted entity with `Id`.
If step 1 succeeds and step 2 fails, audio is orphaned in the vault (no rollback today).
The Opus transcode derived-artifact path (`OpusTranscodeService.TranscodeAndStoreAsync`) also streams its entire pipeline: extension and duration are read from the vault index (no body load); the source bytes are opened via `TrackContentService.OpenAudioMediaStreamAsync` and bounded-copied to a staging file; the encoded Ogg output is walked from a `FileStream` via `OggOpusParser.WalkAsync(Stream)` (bounded one-page-at-a-time buffer; byte-identical to the retained whole-buffer oracle `Walk(ReadOnlySpan<byte>)`); and stored via `RegisterResourceStreamingAsync`. The sidecar (a few KB, inherently bounded) retains the whole-buffer write. Completes the store-path OOM-fix arc.
### Streaming-first audio playback
The player is not fetch-then-play:
1. Client calls `GET api/track/{id}` on DeepDrftContent and receives WAV bytes as a stream (`HttpCompletionOption.ResponseHeadersRead`).
2. `StreamingAudioPlayerService` reads in adaptive 1664 KB chunks, pushes each via `AudioInteropService.processStreamingChunk`.
3. TypeScript `StreamDecoder` parses WAV header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
1. Client calls `GET api/track/{id}` on DeepDrftContent and receives the first bounded segment (`Range: bytes=0-{SegmentSizeBytes-1}`, 4 MB) via `HttpCompletionOption.ResponseHeadersRead`.
2. `StreamingAudioPlayerService` reads in adaptive 1664 KB chunks within each segment, pushes each via `AudioInteropService.processStreamingChunk`.
3. TypeScript `StreamDecoder` parses the format header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}` with `Range: bytes={byteOffset}-`. Server streams raw bytes from that file-absolute offset with a `206 Partial Content` response. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
5. **Seek beyond buffer**: if seek target is past what's decoded, `StreamingAudioPlayerService` issues a new bounded segment starting at the seek byte offset. Server responds 206; player retains the parsed header and feeds the raw continuation into the existing decode pipeline.
**Memory bounding (three complementary layers, all required):**
- **Raw-queue bound (`StreamDecoder`):** `releaseConsumedChunks()` front-compacts `rawChunks` after each aligned segment is decoded, using a `discardedBytes` absolute cursor so all offsets remain absolute even as the array's front moves. Without this, a long WAV (e.g. a 92-min mix ≈ 970 MB raw) accumulates its entire decoded-from body in `rawChunks` regardless of the decode-side bounds below.
- **Decoded-queue bound (`PlaybackScheduler`):** `evictPlayedBuffers()` discards already-played `AudioBuffer`s, capping the scheduler's forward fill to a 96 MB ceiling (Phase 21.2). Decoded PCM is larger than source (Web Audio uses 32-bit float — a 16-bit stereo WAV roughly doubles; Opus decodes to the same float footprint).
- **Network bound (segmented fetch, Phase 21 Direction B):** The forward stream is fetched as sequential bounded `bytes=cursor-{cursor+4MB-1}` Range requests via `RunSegmentedStreamAsync`; the next segment is fetched only after `DrainBackpressureAsync` confirms the scheduler is below low-water. Because each segment is fully consumed before the next is issued, the browser holds at most ~one segment of raw bytes. A per-chunk drain inside the segment loop (gated on `_streamingPlaybackStarted` so it cannot deadlock first-audio) additionally prevents high-density codecs (e.g. Opus, where a 4 MB segment is ~100 s of audio) from decoding the whole segment eagerly ahead of the playhead before the inter-segment gate runs.
**Playback stability invariants (streaming-stabilization arc):**
- **Back-pressure water marks:** forward fill 60s high / 30s low (`PlaybackScheduler.DEFAULT_FORWARD_HIGH_WATER_SECONDS` / `...LOW_WATER_SECONDS`). Production pauses on `lookahead ≥ high OR decoded bytes > 96 MB ceiling`, whichever first. The time window is a jitter cushion for Opus's async decode ramp; the byte cap is the hard OOM guarantee and is unchanged.
- **Genuine end-of-playback:** `PlaybackScheduler` uses a `streamComplete` flag (set by `setStreamComplete`) combined with an `underrun_` park/resume state to distinguish a drained-but-still-streaming queue (startup/underrun gap → park, resume on refill) from a truly finished track (stream complete AND queue drained → `finishPlayback()`, the single genuine-end path). Prevents the false `onPlaybackEnded` that previously fired when the Opus WebCodecs queue momentarily drained during the decode ramp.
- **Rebuffer hysteresis:** Opus playback start and underrun-resume are gated on `hasMinimumPlaybackLead()` (1s decoded lead, `DEFAULT_MIN_PLAYBACK_LEAD_SECONDS`); WAV keeps `hasMinimumBuffers(6)`. `streamComplete` overrides the gate so a short tail still plays out. `StreamingAudioPlayerService.TryStartPlaybackAsync()` force-starts after `MarkStreamCompleteAsync` when the threshold was never crossed (ultra-short track protection).
- **Opus `AudioContext` pre-aligned to 48 kHz** in `AudioPlayer.initializeStreaming` before any bytes flow, eliminating the mid-decode context teardown that previously OOM'd the tab under software rendering.
**Visualizer / decode contention (decodePressure + hwAccel):**
- `DeepDrftPublic/Interop/audio/decodePressure.ts` exports a shared `DecodePressureSignal` singleton. The audio pipeline (`OpusStreamDecoder` yield-cap events, `PlaybackScheduler` underrun-parking events) calls `report()` on sustained lag; the visualizer calls `isUnderPressure()` each rAF frame. The signal engages only after ≥ 5 reports within 2500 ms with a 1s minimum hold (hysteresis); a lone startup-ramp blip never engages.
- Under pressure, `WaveformVisualizer.ts` throttles its rAF loop to ~15 fps (`PRESSURE_THROTTLE_FRAME_MS = 1000/15`), cutting main-thread WebGL software-render + physics cost so WebCodecs decode recovers. A no-op under HW accel.
- `DeepDrftPublic/Interop/visualizer/hwAccel.ts` probes `WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL` for software-renderer signatures (SwiftShader, llvmpipe, softpipe, Microsoft Basic Render, etc.). Absence of the debug extension is treated as accelerated — lava is disabled only on positive evidence. On first interactive render, `WaveformVisualizerControlState.ApplyCapabilityDefault(bool)` applies a one-time scoped default: `LavaEnabled = false` (the expensive subsystem) when no HW accel is detected, `WaveformEnabled` stays on. Guarded by `_capabilityDefaultApplied`; never overrides an explicit user toggle.
- **Known limitation / deferred escalation:** HW-accel-off Opus playback is made usable by defaulting lava off and throttling under pressure. If starvation recurs (e.g. waveform-only path also proves too costly, or a software renderer slips the probe), the documented next step is moving Opus WebCodecs decode off the main thread (Web Worker / AudioWorklet) so it stops competing with main-thread rendering. Not a current implementation — a recorded fallback.
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
### Theming and dark mode
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DDIcons.cs`.
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
### TypeScript interop, not raw JS
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"``outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
## Development Commands
@@ -112,10 +136,10 @@ dotnet run --project DeepDrftAPI
### Entity Framework (SQL Database)
```bash
# Add migration (from solution root)
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
# Update database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
```
## Key Configuration Files
@@ -123,8 +147,8 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+2 -2
View File
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
- Real per-user accounts (`ApplicationUser` table). No shared password.
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
+958
View File
@@ -6,6 +6,964 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 23 — SEO Crawl Directives (landed 2026-06-23)
**Landed:** 2026-06-23 on dev.
- **What:** Server-side crawl-directive endpoints for `DeepDrftPublic` (`GET /robots.txt` and `GET /sitemap.xml`) plus a defense-in-depth noindex layer for `DeepDrftManager`. The endpoint/file-shaped follow-on to Phase 22's per-page `SeoHead` component. Phase 22 is the *content* of discoverability; Phase 23 is the *directives* layer above it — telling crawlers **which** pages exist and **whether** to crawl at all. No new `DeepDrftAPI` endpoint, no schema change.
- **Why:** Without robots.txt a crawler has no machine-readable signal about which routes to include or exclude (e.g. `/FramePlayer`, `/api/*`). Without sitemap.xml Google/Bing must discover release detail pages by link-following alone. Without noindex/robots protection the CMS could be inadvertently crawled if an admin link ever appeared on a public page.
- **Shape:**
- **`DeepDrftPublic/Controllers/CrawlDirectiveController.cs`** (new): thin controller serving both endpoints. Reads `IWebHostEnvironment.IsProduction()` **directly** — no `SeoEnvironment` PersistentState bridge needed because these are server-side only (nothing crosses the server→WASM seam). Env gate is fail-safe closed: non-production robots.txt emits `Disallow: /` and the sitemap returns 404.
- **`DeepDrftPublic/Seo/RobotsTxt.cs`** (new): pure builder for the robots.txt body. Production: `Allow: /` + `Disallow: /FramePlayer` + `Disallow: /api/` + `Sitemap:` pointer. Non-production: `Disallow: /`.
- **`DeepDrftPublic/Seo/SitemapXml.cs`** (new): pure builder for the sitemap XML body. Walks `GET api/release` (server-to-server via the existing `"DeepDrft.API"` named client, paged) and emits a sitemaps.org `urlset`. Six explicit static roots (`/`, `/about`, `/cuts`, `/sessions`, `/mixes`, `/archive`) plus one `<url>` per release — `<loc>` = `SeoOptions.BaseUrl` + `ReleaseRoutes.DetailHref`, equal to the page's `SeoHead` canonical by construction; `<lastmod>` from `ReleaseDate`. Resilient: a partial/failed release read yields a well-formed roots-only document, never a 500.
- **`DeepDrftManager/wwwroot/robots.txt`** (new static file): `Disallow: /` with no environment gate — the CMS is always uncrawlable, including in production.
- **`DeepDrftManager/Components/App.razor`** (updated): blanket `<meta name="robots" content="noindex,nofollow">` in the CMS host `<head>` — defense in depth against de-indexing URLs discovered via external links, complementing the robots.txt directive.
- **Design memo:** `product-notes/phase-23-seo-crawl-directives.md`.
---
## Phase 22 — SEO Metadata Component (landed 2026-06-23)
**Landed:** 2026-06-23 on dev.
- **What:** A parameterized, reusable SEO head component (`SeoHead.razor`) that emits the full modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD — for every public page in one line of markup. **Public listener site only** (`DeepDrftPublic` host + `DeepDrftPublic.Client`); the CMS is explicitly out of scope. No data-model/schema change, no new API endpoint.
- **Why:** `App.razor` had a static `<head>` with no description, canonical, OG, Twitter Card, or JSON-LD anywhere; pages set only an ad-hoc `<PageTitle>` with an inconsistent suffix. A shared `/mixes/{key}` link unfurled as a bare title + URL. Crawlers and social unfurlers saw nothing useful.
- **Shape:**
- **`Controls/SeoHead.razor`** (new): purely presentational `<HeadContent>` + `<PageTitle>` emitter. Accepts a single `SeoModel` parameter; owns no data fetch. Each page wires it in one line.
- **`Common/SeoModel.cs`** (new): typed per-page input with named factories — `ForRelease(release, baseUrl, options)` (medium-dispatched), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Factories encode the medium→schema mapping in one place. Explicit `SeoModel.Robots` override available; default is environment-gated (see `SeoEnvironment`).
- **`Common/SeoJsonLd.cs`** (new): typed schema.org JSON-LD builders. Cut → `MusicAlbum` with ordered `MusicRecording` track list; Session → `MusicAlbum`/`LiveAlbum`; Mix → single `MusicRecording` with ISO-8601 duration; Home/About → `MusicGroup` (with `sameAs: ["https://instagram.com/deepdrft.music"]`); Browse → `CollectionPage`. `byArtist` wired per-release. JSON-LD body is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- **`Common/SeoOptions.cs`** (new): site-wide config — `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (runs in both server and WASM `Program.cs`).
- **`Common/SeoUrls.cs`** (new): URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (config, not `window.location` — no `window` at server prerender and the origin can't be derived behind the nginx proxy).
- **`Common/SeoEnvironment.cs`** (new): scoped `[PersistentState]` bridge seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
- **Wired into:** Home, About, Cut/Session/Mix detail pages (incl. their not-found branches → `noindex`), the browse views (Albums/Sessions/Mixes/Archive), and the 404 NotFound page.
- **Render-mode correctness:** `SeoHead` rides the existing `PersistentComponentState` bridge (the same `ReleaseDto` the detail pages already bridge) — no new fetch. The `InteractiveAuto` double-render produces identical head content across prerender and WASM passes (fed from bridged state, guarded on id/key equality).
- **Design memo:** `product-notes/phase-22-seo-metadata-component.md`.
---
## Phase 21 — Windowed Streaming Buffer (landed 2026-06-24)
**Landed:** 2026-06-24 on `streaming-overhaul`.
- **What:** Bounded client memory for long audio streams. Playing a 1 GB+ DJ Mix (the Phase 9 `Mix` medium — a single long track) no longer accumulates the full decoded PCM in the browser; instead the player holds only a sliding forward window and discards what has already played. Four waves, all landed. Public listener site only (`DeepDrftPublic.Client` player stack + `DeepDrftPublic` TypeScript audio interop); no CMS, no API endpoint, no schema change.
- **Why:** The `PlaybackScheduler` held an `AudioBuffer[]` it never evicted — both decode paths (`StreamDecoder`/`IFormatDecoder` for WAV/MP3/FLAC, and `OggDemuxer``OpusStreamDecoder` for Opus) pushed into it without limit. Decoded PCM is larger than the source (Web Audio is 32-bit float per sample/channel — a 16-bit stereo WAV roughly doubles once decoded; a low-data Opus mix decodes to the same float footprint regardless of how few compressed bytes arrived), so a 1 GB WAV or Opus mix could accumulate ~2 GB of retained float in the browser. The Opus path also had a second upstream accumulation locus: the WebCodecs `AudioDecoder` work queue and `decodedQueue: AudioData[]`.
- **Shape (by wave):**
- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; load-bearing; shared by both paths).** Drop already-played buffers while keeping position/index/time-anchor bookkeeping exact against a buffer array that no longer begins at absolute time 0. Written once, serves both decode paths (they `addBuffer` identically). This is the hardest correctness work in the phase. No refill yet.
- **21.2 — Back-pressure (one fill signal, two throttle sites).** Bound the unplayed region by stopping production above a high-water mark and resuming below low-water, driven by `PlaybackScheduler.evaluateProductionPause()` — a single shared scheduler-fill signal (OQ6/OQ7 resolved: shared controller, per-path hook). **21.2a**`StreamingAudioPlayerService` stops fetching the next segment above high-water, polling `evaluateProductionPause()` at 100 ms cadence until the fill drains below low-water (serves both paths). **21.2b** — the Opus demux/decode feed is additionally stopped when the same signal is set, so the WebCodecs decode queue and `decodedQueue` do not balloon behind a paused segment loop (Opus only; no WAV analogue). Together with 21.1 this bounds both the played and unplayed sides on both formats.
- **21.3 — Seek-back-past-window refill + clean failure recovery.** When a backward seek lands earlier than the retained tail, the existing seek-beyond-buffer path is reused pointed at the earlier offset (whichever resolver the active path ships: `IFormatDecoder`/`StreamDecoder` for WAV; the live `resolveOpusByteOffset` + `OpusStreamDecoder.reinitializeForRangeContinuation` for Opus). Minimal AC6 refill-failure handling added: `RecoverFromFailedRefill` clears the scheduler and surfaces a user-visible error rather than leaving the player wedged.
- **21.4 — Validation + Direction A→B pivot (the critical wave — see as-built divergence below).** Network-memory bounding confirmed in Daniel's browser run: the segmented approach delivers ~4 MB segments pacing with playback, with the browser holding ~one segment of raw bytes rather than the full artifact.
- **As-built divergence — Direction A→B pivot (important).** The spec recommended **Direction A** (sliding window on one open-ended forward stream, relying on pausing `ReadAsync` / the segment loop to backpressure the socket) with **Direction B** (discrete bounded `Range: bytes=start-end` segments, each fetched only when the scheduler drains below low-water) held as the documented fallback "if Direction A's back-pressure proves leaky in practice."
**21.4 browser validation proved it leaky.** On Blazor WASM, the browser `fetch` API buffers the entire HTTP response body regardless of how slowly the application reads it: pausing reads bounded the *decode* but not the *network download*, so the entire ~970 MB body accumulated in browser memory even though the application only decoded a window of it. Direction A's core assumption — that pausing `ReadAsync` closes the TCP flow-control window before the browser caches the whole body — does not hold in the WASM `fetch` runtime.
**We pivoted to Direction B.** The forward stream now issues sequential bounded `bytes=cursor-(cursor+SegmentSizeBytes-1)` Range requests (`SegmentSizeBytes = 4 MB`), each fetched only after the scheduler drains below low-water — via `RunSegmentedStreamAsync` in `StreamingAudioPlayerService`. Because each 4 MB request is fully consumed before the next is issued, the browser holds at most ~one segment of raw bytes regardless of file size. The decode-side windowing (21.1/21.2) pairs with it: the segment loop's segment-gate replaces the raw `ReadAsync` pause as the production throttle (21.2a), and the Opus decode-ahead throttle (21.2b) hooks the same `evaluateProductionPause()` signal. Seek and refill converge on the same segmented loop. Direction B is recorded as the shipped approach; Direction A is recorded as tried-in-validation and found insufficient for the WASM `fetch` runtime.
- **What Phase 21 does NOT include:** the full AC matrix re-run (Opus seek-storm, visualizer, rapid-seek concurrency under the new segmented loop) beyond the network-memory bounding confirmed in 21.4. Those acceptance criteria remain the validation baseline for any follow-on work touching this seam.
- **Design memo:** `product-notes/phase-21-windowed-streaming-buffer.md` (note: the spec recommended Direction A; the as-built pivot to Direction B is annotated at the top of that doc and in §3.2/§3.3).
### Phase 21 — Post-landing: raw-queue and per-chunk bounds (landed 2026-06-24)
**Landed:** 2026-06-24 on `streaming-overhaul`. Fixes a Chrome tab crash (main-process OOM with hardware acceleration OFF) on long-track playback. Two unbounded memory queues remained after Phase 21 landed; this patch adds complementary bounds on both.
1. **`StreamingAudioPlayerService` — per-chunk drain inside segments.** Phase 21 Direction B's inter-segment back-pressure gate is matched to WAV byte density (~24 s per 4 MB segment), but not to Opus: at 320 kbps a 4 MB segment is ~100 s of decodable audio. The inner chunk loop had no fill check, so the entire segment decoded eagerly ahead of the playhead — piling decoded f32 PCM into main-process RAM before the inter-segment gate ever ran. Fix: an extracted `DrainBackpressureAsync` helper (shared with the inter-segment gate) is called per chunk once the scheduler is over high-water, gated on `_streamingPlaybackStarted` so it cannot deadlock first-audio (the playhead doesn't advance until playback starts, so draining to low-water would block indefinitely before the first buffer plays). Also added load-generation diagnostic logging (`_loadGeneration`) to confirm single-load-per-play-action in-browser.
2. **`StreamDecoder.ts``releaseConsumedChunks()` front-compaction of `rawChunks`.** `rawChunks` retained the entire decoded-from stream body because consumed chunks were never released — a 92-min WAV mix (≈ 970 MB raw) accumulated all of it in the browser. Phase 21.2's 96 MB decoded-side ceiling does not reach this raw queue. Fix: `releaseConsumedChunks()` walks `rawChunks` after each aligned segment is decoded and splices off all fully-consumed chunks (any chunk whose end ≤ `processedBytes`), advancing `discardedBytes` (an absolute cursor into the logical stream) so `extractAlignedData` can continue walking from the correct position even though `rawChunks[0]` no longer begins at byte 0. This is the raw-side analogue of `PlaybackScheduler.evictPlayedBuffers` (the decoded side).
Together with the existing decoded-side 96 MB ceiling (Phase 21.2) and the segmented fetch (Phase 21 Direction B), these complete the three-layer memory bound: raw queue, decoded queue, and network.
### Phase 21 — Post-landing: streaming stabilization (landed 2026-06-26)
**Landed:** 2026-06-26 on `streaming-overhaul`. Resolves playback instability that surfaced specifically with Opus + hardware acceleration off: the Opus underrun/false-end cycle, visualizer starvation of WebCodecs decode, and an OOM path from mid-decode `AudioContext` teardown. All changes are TypeScript-only (`DeepDrftPublic/Interop/audio/` + `DeepDrftPublic/Interop/visualizer/`) plus the C# service that calls them; no API, no schema, no CMS change.
1. **Back-pressure water marks widened to 60s high / 30s low.** `PlaybackScheduler.ts` `DEFAULT_FORWARD_HIGH_WATER_SECONDS` / `DEFAULT_FORWARD_LOW_WATER_SECONDS` doubled from 30/15. The 96 MB decoded-byte ceiling is unchanged and remains the hard OOM bound; production pauses on `lookahead ≥ high OR bytes > cap`, whichever first. The wider time window absorbs per-packet WebCodecs decode jitter (Opus 48 kHz stereo ≈ 0.37 MB/s → 60 s ≈ 23 MB — well within the cap) without false back-pressure on the Opus decode ramp.
2. **End-of-playback gated on genuine completion (`streamComplete` + `underrun_`).** `PlaybackScheduler` now distinguishes a drained-but-still-streaming queue (startup/underrun gap) from a truly finished track (stream complete AND queue drained). `streamComplete` (set by `setStreamComplete`, called from `AudioPlayer.markStreamComplete`) is the discriminator. When the queue drains while `streamComplete` is false the scheduler parks in `underrun_` state (anchors frozen, `isActive_` false) and resumes from `scheduleNewBuffers` when buffers arrive. `finishPlayback()` is the single genuine-end path, reached only when both conditions hold simultaneously. Previously a drained Opus queue during the WebCodecs startup ramp fired `onPlaybackEnded`, nulling Duration and advancing the queue.
3. **Rebuffer hysteresis — 1s decoded-lead gate.** `hasMinimumPlaybackLead()` gates Opus playback START and underrun RESUME (`DEFAULT_MIN_PLAYBACK_LEAD_SECONDS = 1.0`), preventing the decode-ramp thrash (resume on a 20 ms Opus packet → drain immediately → re-park → repeat). `streamComplete` overrides the gate so a short tail plays out rather than parking forever. WAV keeps its `hasMinimumBuffers(6)` buffer-count start gate unchanged — large synchronous WAV segments rarely underrun and that gate's character must not change. **Complete-without-start force-start:** `StreamingAudioPlayerService.TryStartPlaybackAsync()` is called after `MarkStreamCompleteAsync` when `_streamingPlaybackStarted` is still false, preventing an ultra-short Opus track (total audio below the 1s lead) from sitting loaded-but-not-playing.
4. **Opus `AudioContext` pre-aligned to 48 kHz.** `AudioPlayer.initializeStreaming` calls `contextManager.recreateWithSampleRate(OPUS_SAMPLE_RATE)` when the content type resolves as Opus, before any bytes flow. The context is already at 48 kHz when the decoder's own lazy `recreateWithSampleRate` check runs, so it short-circuits (no-op). Eliminates the mid-decode context teardown-and-rebuild that previously double-decoded the stream and OOM'd the tab under HW accel off.
5. **`decodePressure` singleton + `WaveformVisualizer` auto-throttle (~15 fps under pressure).** `DeepDrftPublic/Interop/audio/decodePressure.ts` exports a process-wide `DecodePressureSignal` singleton shared between the audio pipeline (producer: `OpusStreamDecoder` yield-cap events and `PlaybackScheduler` underrun-parking events) and the visualizer (consumer). Engages only on sustained stress (≥ 5 `report()` calls within a 2500 ms window) and holds for a 1000 ms minimum dwell before releasing, suppressing lone startup-ramp blips. `WaveformVisualizer.ts` checks `decodePressure.isUnderPressure()` each rAF frame; when engaged it skips frames so at most one renders per `PRESSURE_THROTTLE_FRAME_MS` (`1000/15` ms, ~15 fps), cutting main-thread WebGL software-render + physics cost so WebCodecs decode recovers. A no-op under HW accel — healthy decode never calls `report()` and `isUnderPressure()` stays false indefinitely.
6. **Hardware-acceleration detection + `ApplyCapabilityDefault`.** `DeepDrftPublic/Interop/visualizer/hwAccel.ts` probes `WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL` for known software-renderer signatures (SwiftShader, llvmpipe, softpipe, Microsoft Basic Render Driver, Mesa Offscreen, "software"). Absence of the debug extension is treated as accelerated — the probe disables lava only on positive evidence of software rendering, not on uncertainty. `WaveformVisualizerControlState.ApplyCapabilityDefault(bool hardwareAccelerated)` is called once per session by the visualizer bridge on first interactive render: when `hardwareAccelerated` is false it sets `LavaEnabled = false` (the expensive main-thread subsystem) while leaving `WaveformEnabled` on, coerces Theater Mode, and raises `Changed` once so all observers see the default in a single cycle. Guarded by `_capabilityDefaultApplied` so it runs at most once per session and never overrides an explicit in-session user toggle. With HW accel present the call is a no-op.
---
## Phase 18 — Opus Low-Data Streaming (landed 2026-06-23)
_Note: Two distinct efforts share the "Phase 18" label — phase numbers are organisational, not sequential. See also **Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)** below._
**Landed:** 2026-06-23 on `streaming-overhaul`.
- **What:** Dual-format audio delivery — the existing lossless WAV path plus a new low-data Ogg Opus (fullband, 320 kbps) path — giving listeners a quality choice. Opus is the default (capability-gated). Six waves, all landed.
- **Why:** Lossless WAV works but imposes high bandwidth cost, particularly for long DJ Mixes. Opus at 320 kbps is indistinguishable for typical listening and an order of magnitude smaller, making the site usable on metered or constrained connections. The bespoke Web Audio decode→schedule graph is retained by deliberate choice; Opus feeds the same `IFormatDecoder` seam rather than an HTML `<media>` element or MSE.
- **Shape (by wave):**
- **18.1 — Ingest transcode + seek-index sidecar.** New `OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from `UnifiedTrackService.UploadAsync` as a background job (failure-tolerant; the track plays lossless first). Produces the Ogg Opus 320 artifact and a combined seek/setup **sidecar** (0.5 s-bucketed granule→byte index + `OpusHead`/`OpusTags` setup header), both stored in a new `track-opus` vault. **ffmpeg/libopus** added to the deploy bootstrap as a runtime prereq on the API host.
- **18.2 — Storage + lookup contract.** Server-side "given `EntryKey` + format, return the right artifact + content-type, with lossless fallback when no Opus artifact exists."
- **18.3 — Delivery: `?format=opus|lossless` + sidecar serving + proxy threading.** `?format=` param on the stream endpoint (Range preserved); sidecar-serving endpoint; param forwarded through `TrackProxyController`; `TrackMediaClient` sends the format param.
- **18.4 — Client Opus decoder + index-based seek + capability detection.** New `OpusFormatDecoder` (`IFormatDecoder`): Ogg-page demux, setup-header carry from the cached sidecar, `calculateByteOffset` binary-searching the precomputed seek index (VBR-safe accurate seek — not interpolation). Capability gate falls back to the lossless path on browsers without Opus support. Testing-phase fixes also landed: a valid ffmpeg-generated capability-probe sample and correct duration reporting (fixed the seekbar + visualizer in Opus mode).
- **18.5 — End-to-end integration.** Player format selection (Opus-by-default, capability-gated); sidecar fetch/inject on track load; **Backfill-Opus** CMS bulk action (third sibling to Generate-Profiles / Backfill-High-res); replace-audio regenerates Opus + sidecar.
- **18.6 — Settings menu + quality toggle + CMS status.** New public-site **Settings menu** (app-bar trigger + settings-item abstraction + `ListenerSettings`, persisted via the dark-mode-pattern seam: `streamQuality` cookie → prerender-read → `PersistentComponentState` → client cookie service); streaming-quality toggle (Low-data/Lossless) as its first occupant. CMS **Opus-status badge** on the track grid. CMS upload meter **Post-Processing** phase visualizing background Opus transcode.
- **As-built divergence — the Opus decoder (important).** The originally-spec'd "segment-and-decode-each-page via `decodeAudioData`" model was implemented and then **replaced**: browser testing showed it glitches at every segment boundary and broke seeking (Opus has pre-skip + inter-frame state, so independent per-segment decode is architecturally wrong). The landed decoder is a **WebCodecs `AudioDecoder` streaming pipeline** (Ogg demux → packets → stateful continuous decode), with index-driven seek and a WebCodecs-based capability gate. Daniel's call at the decision point — offered a decode-whole stopgap (Option A) vs. the proper WebCodecs streaming decoder (Option B), he chose B: "Let's go for B, why do you like half-solutions so much" (the same no-stopgap posture as the Mix visualizer WebGL2 decision, where he said "no stopgap — WebGL as step 1, no pussyfooting"). The segmented approach is recorded as tried-and-replaced.
- **What Phase 18 does NOT include:** windowed / bounded-memory streaming. Phase 18 still fetches the whole (smaller) Opus artifact; bounding client memory to a sliding forward window is **Phase 21's job**.
- **Design memo:** `product-notes/phase-18-opus-low-data-streaming.md`.
---
## Phase 20 — Theater Mode (landed 2026-06-20)
**Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev.
- **What:** A presentation-only Theater Mode toggle on the three public Release Detail views (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`). Toggling ON hides the release page content via `@if` so the lava-lamp + waveform visualizer fills the surface unobstructed; the player bar grows to surface the playing release's cover art, release title (linked), and a release-mode `SharePopover`. Toggling OFF restores the page byte-for-byte. The top action row (back link, lava-lamp popover, Theater toggle) stays visible in both states. Behavior is identical across all three mediums. Persists across SPA navigation within a session; resets to OFF on fresh page load.
- **Why:** The visualizer is the site's most distinctive feature (Phases 10/12/15). Theater Mode makes it the *whole* thing on demand — a "lean back and watch the lamp" experience — and relocates the minimum release identity to the one piece of chrome that stays (the player bar), so nothing essential is lost.
- **Shape:**
- **`Controls/TheaterModeToggle.razor`** (new): shared toggle button placed immediately left of the lava-lamp `WaveformVisualizerControlPopover` on all three detail pages inside a `.dd-detail-top-actions` cluster. Material `Theaters` glyph; `.dd-accent-icon` for green-accent in both themes. Visible only when `LavaEnabled || WaveformEnabled`; disabled until interactive. Flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Subscribes to `State.Changed` for its own active-state re-render; disposes cleanly.
- **`Controls/AudioPlayerBar/NowShowingPanel.razor`** (new): presentational "now showing" band rendered by `AudioPlayerBar` only when `TheaterMode && CurrentTrack?.Release is not null`. Shows cover art (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title link (`ReleaseRoutes.DetailHref`), and release-mode `SharePopover` in `.dd-accent-icon`. Layout CSS in `AudioPlayerBar.razor.css` (`.now-showing-*`); surface/text bind `--deepdrft-page-*` aliases — no new dark overrides.
- **`Services/WaveformVisualizerControlState.cs`** (widened): gained `TheaterMode` bool + `DefaultTheaterMode = false` const, and `CoerceTheaterMode()` — enforces the invariant that Theater Mode cannot remain on when both subsystems are off. Called from `WaveformVisualizerControls.ToggleLava`/`ToggleWaveform` before `NotifyChanged()` so all observers see a consistent coerced state in the same `Changed` cycle.
- **`Controls/AudioPlayerBar/AudioPlayerBar.razor` + `.razor.cs` + `.razor.css`**: subscribes to `WaveformVisualizerControlState.Changed`; mounts `<NowShowingPanel>` above transport controls when Theater is on and a release is playing.
- **Three detail pages** (`CutDetail.razor`, `MixDetail.razor`, `SessionDetail.razor`): page-level `@if (!VisualizerControlState.TheaterMode)` gates content regions on each page individually (not in `ReleaseDetailScaffold`, so Session — which does not use the scaffold — is covered identically). Each page's top action cluster hosts `<TheaterModeToggle />` in a `.dd-detail-top-actions` flex wrapper.
- **`deepdrft-styles.css`**: new `.dd-detail-top-actions` layout-only class (`display:flex; align-items:center; gap:0.25rem`) — no colour; shared by all three pages.
- **`DeepDrftTests/WaveformVisualizerControlStateTests.cs`** (new): unit tests for the `CoerceTheaterMode()` auto-exit invariant.
- **Design memo:** `product-notes/phase-20-theater-mode.md`.
### Phase 20 — Wave 2 — Theater Mode refinements (landed 2026-06-21)
**Landed:** 2026-06-21 on dev.
- **What:** Three refinements to the base Phase 20 feature. (1) **Full-screen detail body:** each detail page's foreground container gained `.dd-detail-fill` (`min-height: calc(100vh - var(--deepdrft-nav-height, 88px))`), so the visualizer reads as full-screen and the footer is pushed below the fold regardless of Theater Mode. (2) **Eased collapse (no pop):** the hard `@if` content-hide on the three detail pages was replaced by a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` wrapper pair that receives `.dd-theater-collapsed` when `IsContentHidden` is true — animates `grid-template-rows: 1fr → 0fr`, `opacity`, and `visibility` (deferred via `transition-behavior: allow-discrete`) so Theater ON/OFF eases rather than pops; `prefers-reduced-motion` collapses instantly. The same wrapper pattern drives the player-bar `NowShowingPanel`, which is now kept mounted whenever a release is playing and collapsed (not `@if`-removed) when Theater is OFF — enabling the ease-in when Theater turns ON (resolves OQ2 design intent for a mounted-but-dormant panel). (3) **Playing-release scoping:** Theater Mode now only applies to the currently-playing release. `ReleaseDetailBase` and `CutDetailBase` each gained a cascaded `IStreamingPlayerService PlayerService`, a reference-guarded `StateChanged` subscription (disposed in `Dispose`), and three predicates: `IsThisReleasePlaying` (`CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). `TheaterModeToggle.razor` gained an `Available` parameter (default `true`) folded into its render gate; all three pages pass `Available="ShowTheaterToggle"`. A detail page whose release is not playing shows no toggle and ignores the global `TheaterMode` flag.
---
## Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)
_Note: Two distinct efforts share the "Phase 18" label — phase numbers are organisational, not sequential. See also **Phase 18 — Opus Low-Data Streaming (landed 2026-06-23)** above._
**Landed:** 2026-06-19 on dev (Wave 1 + Wave 2 + Wave 3).
- **What:** A DRY token pass resolving six theming symptoms (five in dark mode, one in light) that all traced to three root causes: neutral page surfaces bound to constant brand tokens, the play chip bound to a constant light-grey, and no theme-aware popover-surface token. Resolved as one coherent pass via a shared token layer rather than per-component patches.
- **Why:** Symptom consolidation and root-cause analysis showed all six symptoms shared the same underlying structure — component CSS bypassing the theme-aware alias layer and binding constant source tokens directly. A single additive token pass in `deepdrft-tokens.css` plus targeted re-pointing of consumers fixes all six without scattering dark-mode rules.
- **Shape:**
- **Token foundation (`deepdrft-tokens.css`):** Three new theme-aware token families added to `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css`, each defined in both `:root` (light) and `.deepdrft-theme-dark` (dark):
- `--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` — neutral page surface family. Light: `--deepdrft-white` / `--deepdrft-navy` / `--deepdrft-muted`. Dark: `var(--mud-palette-background)` (#0D1B2A, the true page ground) / `--deepdrft-white` / `color-mix(muted 70%, white)` — neutral sections dissolve into the site background as one continuous dark field rather than reading as raised panels.
- `--deepdrft-play-chip` / `--deepdrft-play-glyph` / `--deepdrft-play-chip-soft` — play-chip family. Light: soft-grey chip (matching prior `--deepdrft-soft`). Dark: `--deepdrft-green-accent` chip + `--deepdrft-navy` glyph (navy-on-green for solid chips); `--deepdrft-play-chip-soft` is `color-mix(green-accent 30%, transparent)` (the player-bar translucent override).
- `--deepdrft-popover-surface` — popover surface. Light: `color-mix(navy 8%, white)` soft desaturated-navy wash. Dark: `#162437` (pixel-identical to `DeepDrftPalettes.Dark.Surface` — dark popovers unchanged, only light is retoned).
- **Neutral-surface inversion (T2):** `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css` re-pointed from constant `--deepdrft-white`/`--deepdrft-navy` to `--deepdrft-page-surface`/`--deepdrft-page-text`. Decorative navy/green sections (`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) untouched — classification encoded in which token each section binds.
- **Play-chip theming (T3):** `PlayStateIcon.razor.css` `.icon-container` re-pointed to `--deepdrft-play-chip`; glyph to `--deepdrft-play-glyph`. Player-bar context overrides chip to `--deepdrft-play-chip-soft` (translucent green wash). Light-mode parity and connect-option hover also corrected.
- **Popover surface (T4):** `deepdrft-styles.css` binds `--deepdrft-popover-surface` to the MudBlazor default popover surface. Bespoke dark-glass panels (`--deepdrft-panel-ground`) untouched.
- **Wave 2 refinements (on top of T1T4):** App bar background moved to navy (`#112338`) from near-black (`#0D1B2A`). Neutral page surfaces re-pointed to `var(--mud-palette-background)` (`#0D1B2A`) as the true dark ground — sections dissolve into the body background rather than reading as navy-mid raised panels (resolves Wave 1's open question in favour of ground). Dark-mode hero legibility (superseded in Wave 3 — see below). Play-glyph settled on navy-on-green (solid chips) and green-on-green (player bar, via `--deepdrft-play-chip-soft`).
- **Wave 3 — hero dark-mode legibility fix:** `DeepDrftHero.razor.css` hero text re-worked to bind theme-aware tokens directly in the base rules rather than via `:global(.deepdrft-theme-dark)` overrides (matching the About page's proven pattern). `.hero-title` and `.hero-desc` now bind `--deepdrft-page-text` directly; `.hero-subtitle` (previously bound to the constant `--deepdrft-muted`) now binds `--deepdrft-page-text-muted`, making it theme-aware for the first time. Only `.hero-title em` retains an explicit dark override (`:global(.deepdrft-theme-dark) .hero-title em``--deepdrft-green-accent`, lifting the low-contrast `--deepdrft-green` on the dark ground). Global hero-button dark treatment added to `deepdrft-styles.css`: `.deepdrft-theme-dark .btn-primary``--deepdrft-green-accent` fill + `--deepdrft-navy` text (hover: `--deepdrft-green-interactive`); `.deepdrft-theme-dark .btn-ghost``--deepdrft-page-text` color + `--deepdrft-border-light` border.
- **Open questions resolved:** Dark neutral surface = ground (continuous field, `--mud-palette-background`) — not elevated navy-mid. Popover target: `color-mix(navy 8%, white)` in light; dark binds `#162437` (MudBlazor dark Surface) unchanged.
- **Design memo:** `product-notes/theme-dark-mode-remediation.md`.
### Phase 18 — Wave 4 — Popover-surface retune + portaled-popover body-class bridge (landed 2026-06-20)
**Landed:** 2026-06-20 on dev.
- **What:** Follow-on retune of `--deepdrft-popover-surface` values and a root-cause fix for portaled MudBlazor popovers that were never reaching the dark token.
- **Why:** Wave 13 shipped `--deepdrft-popover-surface` light at `color-mix(navy 8%, white)` (too saturated — read as a grey slab) and dark at flat `#162437`. More importantly, MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark token never applied to them at all. Both needed fixing as a pair.
- **Shape:**
- **Token retune (`deepdrft-tokens.css`):** Light value changed from 8% → 4% navy mix (near-page-background, clearly light). Dark value changed from `#162437` to `color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%)` — a bluer navy with a slight green accent. Dark value hoisted into a new source token `--deepdrft-popover-surface-dark` (defined once in `:root`), referenced by both the `.deepdrft-theme-dark` wrapper block and a new `body.deepdrft-theme-dark` block so portaled content is reached from either selector.
- **Portaled-popover body-class bridge (`MainLayout.razor` + new TS module):** `MainLayout.razor` now stamps/removes `deepdrft-theme-dark` on `<body>` after each render via a new `DeepDrftShared.Client/Interop/theme/theme.ts` module exporting `setBodyThemeClass(isDark: boolean)`. Lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`. Call is gated to fire only on first render or when `_isDarkMode` changes (`_lastAppliedDarkMode` comparison) — no redundant JS calls on unrelated re-renders. `IJSObjectReference _themeModule` is disposed in `DisposeAsync` to clean up the module reference when the circuit tears down.
### Phase 18 — Wave 5 — Glass-panel theme-aware token family (landed 2026-06-20)
**Landed:** 2026-06-20 on dev.
- **What:** The three `MudOverlay`-based glass panels — the queue panel (`.deepdrft-queue-modal`), the waveform visualizer control deck, and the privacy modal — now render as a light translucent glass with legible dark text in light theme, while remaining the existing dark-glass charcoal in dark theme. Dark mode is visually unchanged; a latent white-on-light bug in the inline embed queue row was incidentally fixed by the token flip.
- **Why:** Prior to this wave, all three panels were bound to the constant `--deepdrft-panel-ground` token, exempting them from the theme-aware alias layer established in Waves 13. In light theme this produced white text on a near-white glass surface — unreadable. The panels needed their own theme-aware family (separate from `--deepdrft-popover-surface`, which targets MudBlazor default popovers) and the same `body.deepdrft-theme-dark` portal-scope treatment introduced for popovers in Wave 4.
- **Shape:**
- **New token family (`deepdrft-tokens.css`):** `--deepdrft-panel-surface` / `--deepdrft-panel-text` / `--deepdrft-panel-text-muted` / `--deepdrft-panel-border` / `--deepdrft-panel-row-hover` — each defined in `:root` (light values: translucent glass with dark text), `.deepdrft-theme-dark` (dark-glass charcoal with light text, sourced from the existing `--deepdrft-panel-ground` constant), and `body.deepdrft-theme-dark` (same dark values re-declared so the tokens resolve correctly when the panels portal to `<body>` via `MudOverlay`).
- **Consumer re-pointing:** The three panels and their descendants (queue rows, visualizer deck, privacy modal) previously bound `--deepdrft-panel-ground` directly; they are now re-pointed to the appropriate `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` aliases.
- **Exemption lifted:** This deliberately removes the previously-documented exemption of these panels from the theme-aware layer. `--deepdrft-panel-ground` is now consumed only as the dark-theme value of `--deepdrft-panel-surface`, not directly by any component CSS.
---
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The Fixed (embed) mode queue panel and the OQ1 Option-A iframe resize handshake. Release embeds now render an always-shown, read-only queue panel below the player-bar controls; the Queue button collapses/expands that panel and posts the iframe's new height to the host page so the outer `<iframe>` element resizes to match. Single-track embeds (TrackEntryKey mode) have no queue, no panel, and no Queue button — unchanged compact behaviour. Phase 17 is now complete (all four waves landed).
- **Why:** Phase 11 wave 11.F armed release embeds with a queue (skip navigation, auto-advance), but the viewer had no way to see or jump within the queue. Wave 17.3 surfaces it in Fixed mode — read-only because a shared embed is not an editable playlist — and resolves OQ1 (Option A confirmed feasible: `postMessage` resize degrades gracefully if the host strips the script).
- **Shape:**
- **Fixed embed queue panel** (`AudioPlayerBar.razor`): rendered conditionally on `ShowFixedPanel && _fixedPanelOpen` inside `.deepdrft-queue-embed-panel`; hosts `<QueueList Items="QueueItems" CurrentIndex="QueueCurrentIndex" Editable="false" OnJump="@OnQueueJump" />`. Read-only: no drag handles, no remove buttons. Row-jump (OQ2) calls `PlayRelease(Items, index)` — coherent from the armed-but-not-started state (`PlayRelease` already clears `IsArmed` and materializes a defensive copy).
- **Queue button in Fixed mode** (`PlayerTransportZone`): toggles `_fixedPanelOpen`; triggers a height post after the panel renders. Gated on `ShowFixedPanel` so single-track embeds see no button.
- **`EmbedSnippetBuilder.cs`** (`DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs`): `ForRelease` now mints a per-snippet random token (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`). Token is used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}`. Taller iframe height (release: 384 px vs. track: 196 px). Carries a host-side `<script>` listener that matches incoming `{type:"deepdrft-embed-resize", embedId}` messages against the snippet's own token and sets `iframe.style.height` — multiple release embeds on one host page resize independently (no cross-talk). Degrades to Option B if the host strips the script (panel still works inside the iframe at expanded height). `ForTrack` is unchanged (compact height 196 px, no script, no id token).
- **`embed-frame.ts`** (`DeepDrftPublic/Interop/embed/embed-frame.ts`; compiled output gitignored): new TypeScript interop module. Reads `EmbedId` from `window.location.search` once at module load; exports `postHeight(element: HTMLElement)` — measures the player element's rendered height (`Math.ceil(getBoundingClientRect().height) + 2`), builds `{type:"deepdrft-embed-resize", height, embedId?}` payload (omits `embedId` when absent for backward-compatible degradation), and calls `window.parent.postMessage(payload, "*")`. No-ops when not framed (`window.parent === window`) or the element is unmeasurable.
- **CSS** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): new `deepdrft-queue-embed-panel` and related `deepdrft-` embed-panel classes for the fixed queue panel chrome.
- **Tests** (`EmbedSnippetBuilderTests`): height divergence (ForRelease taller than ForTrack), ForTrack-unchanged (height 196, no script), id uniqueness (two ForRelease calls yield distinct ids), id/script-token consistency (iframe id matches token in script), EmbedId-in-src (token appears as `EmbedId=` in the iframe src).
---
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The append-only Add-to-Queue affordance on detail pages — a new shared `AddToQueueButton.razor` control wired at every detail-page play site, enabling listeners to add a release or individual track to the queue without interrupting the current track. `ReleaseGallery` browse-grid cards are intentionally excluded (OQ10, deferred).
- **Why:** Phase 11 wave 11.F built the `Enqueue`/`EnqueueRange` append path in the queue engine but gave it no UI entry point. Wave 17.4 lights that dormant path, completing the Add-to-Queue capability Daniel stated as commitment 4 of Phase 17. It was split from 17.2 because it depends only on the existing engine append members (not on 17.1's new `Move`/`RemoveAt`), allowing it to land in parallel.
- **Shape:**
- **`AddToQueueButton.razor`** (`DeepDrftPublic.Client/Controls/AddToQueueButton.razor`): shared append-only button with two modes: track mode (`Enqueue` — called with a single `TrackDto`) and release mode (`EnqueueRange` — called with an ordered `IReadOnlyList<TrackDto>`). Material `PlaylistAdd` glyph; tooltip reads "Add to queue" (track mode) or "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive / when the cascade is absent; append-only — does not play, does not navigate.
- **`CutDetail.razor`** (header): release-mode `AddToQueueButton` beside the header play affordance, passing the `TrackNumber`-ordered track list.
- **`CutDetail.razor`** (track rows): track-mode `AddToQueueButton` beside the per-row play affordance.
- **`SessionDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Session hero play button.
- **`MixDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Mix hero play button.
- **Excluded sites:** `StreamNowButton` (no fixed track to resolve — OQ9) and `ReleaseGallery` cards (no play button today — OQ10, deferred to `TODO.md`).
---
## Phase 17 — Player-Bar Queue View: Wave 17.2 — Docked queue overlay (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The editable docked-player queue overlay — a Queue toggle button in the non-Fixed (docked) player bar and a new `QueueOverlay.razor` modal that hosts the shared `QueueList` in editable mode. Listeners can now see, reorder, remove from, and jump within the queue while a release is playing. Also fixed a pre-existing `QueueChanged` unsubscribe leak in `AudioPlayerBar.DisposeAsync`, hardened `PlayRelease` with a defensive copy, and styled the global `deepdrft-queue-*` CSS classes for the first time (first styling for the `QueueList` classes that 17.1 shipped unstyled).
- **Why:** Phase 11 built the queue engine and Phase 17 wave 17.1 built the shared `QueueList` component, but neither surfaced the queue visually in the docked player. Wave 17.2 delivers Daniel's commitment 2 — a visible, editable queue panel in the non-Fixed player bar.
- **Shape:**
- **Queue toggle button** (`AudioPlayerBar` / `PlayerTransportZone`): shown only when `!Fixed && Items.Count > 0`, placed below the transport-button row and left of the timestamp. Material `QueueMusic` glyph; renders in an active/highlighted state when the overlay is open.
- **`QueueOverlay.razor`** (`DeepDrftPublic.Client/Controls/QueueOverlay.razor`): screen-centered tinted modal borrowing the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`). Panel stops click propagation; scrim-click closes the overlay (drag-safe: capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes if a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode.
- **`AudioPlayerBar` wiring**: reorder → `Move(fromIndex, toIndex)`; remove → `RemoveAt(index)` (auto-closes overlay when queue empties); row-jump → `PlayRelease(Items, index)`; Clear header action → new `ClearUpcoming()`. Fixed pre-existing `QueueChanged` unsubscribe leak in `DisposeAsync`.
- **`QueueList.razor` — current-row remove suppression**: the remove (×) control is now hidden on the currently-playing row (`Editable && !isCurrent`), enforcing OQ3's "the current track cannot be removed" rule in the UI. Reorder of the current row is still permitted.
- **Engine — `ClearUpcoming()`** (`IQueueService` / `QueueService`): new additive member. Removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0`; re-emits `QueueChanged`; touches no playback. Satisfies OQ5's requirement that Clear does not stop or remove the current track.
- **Engine — `PlayRelease` defensive copy**: `PlayRelease` now always materializes a defensive copy of its input list (`tracks.ToList()`) so it can never alias the caller's `Items` list — fixes a row-jump bug where jumping via `PlayRelease(Items, index)` could mutate the live `Items` reference mid-operation.
- **CSS — `deepdrft-queue-*` classes** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): overlay/list chrome classes added to the global stylesheet (portaled overlay content cannot use scoped CSS). This is also the first styling pass for the `QueueList` classes 17.1 introduced without accompanying styles.
---
## Phase 17 — Player-Bar Queue View: Wave 17.1 — Engine additions + shared QueueList (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The queue-engine additions and the shared presentational list component that waves 17.2 and 17.3 will consume. No player-bar wiring, overlay, embed, or Add-to-Queue affordance landed — those remain in waves 17.2 and 17.3.
- **Why:** Wave 17.1 is the cold-start prerequisite for the full Phase 17 queue-view surface. The engine additions are interop-free state mutations that land without any UI decision being made; `QueueList` is the single presentational "view" both the docked overlay and the embedded panel will share (one source, multiple views), so it is cleanest to build and test it before the hosting contexts exist.
- **Shape:**
- **Engine — `Move(int fromIndex, int toIndex)`** (`IQueueService` / `QueueService`): reorders `Items` in-place, adjusts `CurrentIndex` so the same track stays current across the move, re-emits `QueueChanged`. Never re-streams or interrupts the playing track. No-op (no `QueueChanged`) when either index is out of range or the indices are equal. Interop-free; safe during prerender.
- **Engine — `RemoveAt(int index)`** (`IQueueService` / `QueueService`): removes the item at `index`, adjusts `CurrentIndex` (a track before current decrements the index; a track after current leaves it unchanged; removing the current track does not stop playback — the track runs to natural end while `CurrentIndex` resolves to the new slot occupant; removing the last remaining item leaves the queue empty and dormant with `CurrentIndex == -1`). Re-emits `QueueChanged`. No-op when `index` is out of range. Interop-free; safe during prerender.
- **Engine — dormant-`Enqueue` coherence (OQ8):** `Enqueue` and `EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) now set `CurrentIndex` to 0 so a subsequent play/skip is correct. Does **not** auto-play — add is not play. `PlayCurrent` is never called from these paths; the methods remain interop-free.
- **`QueueList.razor`** (`DeepDrftPublic.Client/Controls/QueueList.razor`): purely presentational component. Renders `Items` as an ordered list with the current track marked (position number + `GraphicEq` now-playing icon on the current row). `Editable` flag gates drag-reorder handles and per-row remove controls: when `true`, wraps rows in a `MudDropContainer`/`MudDropZone` for reorder; when `false`, renders a plain `<div>` (read-only; the embed's fixed-order shared queue). Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump` respectively — the component calls no `IQueueService` method itself. Owns no data fetch or player wiring. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
- **`QueueServiceTests`**: T1T10 added, covering `Move` (in-range, out-of-range, same-index no-ops; current-track identity preserved across reorders) and `RemoveAt` (before/after/at current; last-item dormant; out-of-range no-op; playback not stopped).
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.5 — Home Plays-card capstone (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The capstone wave — the live home hero Plays card and the `HomeStatsDto` extension that powers it. `NowPlayingStats.razor`'s third card, previously a static "XXX / Plays (Coming Soon)" odometer placeholder, now renders the live `TotalPlays` figure in the existing odometer treatment with a secondary "N listeners" line (`UniqueListeners`). No new fetch path, no new client service, no migration — the card consumes the same `HomeStatsDto` round-trip the other two cards already use. Privacy footer line (`DeepDrftFooter.razor`, `.deepdrft-footer-privacy`) also landed as part of the same merge: a quiet fine-print disclosure of the anonymous `anonId` token, using the Variant 1 approved copy from `product-notes/phase-16-privacy-note.md`.
- **Why:** The Plays card was deliberately held as the final wave (sequenced bottom-up per Daniel's directive) so the substrate (16.1 capture + rollup, 16.2 bucket/channel, 16.3 anonId + distinct-listener aggregation) would be solid before any read surface appeared. Wave 16.4 (per-target / CMS stats views) was speculative and skipped; the event log supports it later if wanted. With 16.5 landing, Phase 16 is complete.
- **Shape:**
- **`HomeStatsDto` extended** (`DeepDrftModels/DTOs/HomeStatsDto.cs`): two new fields — `TotalPlays` (`long`; site-wide sum of every `play_counter` row's `PartialCount + SampledCount + CompleteCount`, all-time; zero until the telemetry migration is applied — expected, not an error) and `UniqueListeners` (`int`; distinct non-null `anon_id` across all play events, all-time; over-counts by design, honestly labelled "listeners"). No other DTO changes.
- **`StatsController` composition** (`DeepDrftAPI/Controllers/StatsController.cs`): now injects `ITrackService` (existing) **and** `IEventService` (Phase 16 event domain). `GetHome` assembles `HomeStatsDto` in two sequential best-effort reads: track-domain aggregation via `ITrackService.GetHomeStats` (existing; failure returns 500 as before); play/listener figures via `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16; a telemetry failure or not-yet-applied migration leaves them at 0 rather than 500-ing the whole endpoint). Neither domain reaches into the other's tables; the controller is the composition seam only.
- **`IEventService` additions** (`DeepDrftData/IEventService.cs`): `GetTotalPlayCount(ct)``ResultContainer<long>` and `GetDistinctListenerCount(ct)``ResultContainer<int>` (wave 16.3 added the distinct-listener overloads; `GetTotalPlayCount` is the one new member for 16.5).
- **`EventRepository.CountTotalPlaysAsync`** (`DeepDrftData/Repositories/EventRepository.cs`): sums `PartialCount + SampledCount + CompleteCount` directly over `PlayCounters` via LINQ — **not** `PlayCounter.TotalPlays` (which is an EF-ignored computed property and not translatable). An empty counter table sums to 0.
- **`NowPlayingStats.razor`** (`DeepDrftPublic.Client/Controls/NowPlayingStats.razor`): third card now renders `@_stats.TotalPlays` in `.hero-stat-odometer` and `@_stats.UniqueListeners listeners` as `.hero-stat-sub`. No change to the `PersistentComponentState` bridge or `IStatsDataService` fetch path — the DTO fields arrive in the same existing round-trip.
- **`DeepDrftFooter.razor`** (`DeepDrftPublic.Client/Layout/DeepDrftFooter.razor`): privacy disclosure paragraph (`.deepdrft-footer-privacy`) added: "We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag's gone."
- **No migration.** The `play_counter` rollup table was created by `20260619155610_AddPlayShareTelemetry` (wave 16.1; authored, not yet applied — Daniel-gated). The `CountTotalPlaysAsync` query returns 0 gracefully until that migration runs.
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.1 — Foundation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The anonymous telemetry **substrate** — foundation end-to-end with nothing reading it yet. No `anonId` written; no home-card/read surface changed (those are waves 16.3 and 16.5). The full capture-and-storage pipeline is in place: client-side play-session tracker and share tracker, `sendBeacon` transport with page-unload handler, proxied and rate-limited intake endpoints, append-only SQL event log with incremental rollup, and server-side release attribution.
- **Why:** The home hero's Plays stat card (`NowPlayingStats.razor`'s third card) has been a static "XXX / Plays (Coming Soon)" placeholder. Phase 16 builds the anonymous, privacy-light substrate that will eventually power it. Wave 16.1 is the cold-start foundation — nothing reads the log yet; correctness and storage are the deliverable, not the visible metric.
- **Shape:**
- **Client — `PlayTracker`** (`DeepDrftPublic.Client/Services/PlayTracker.cs`): opens a play session on playback start, advances a high-water position on each progress tick (instrumented at `StreamingAudioPlayerService`, not at the HTTP layer, so seek-beyond-buffer re-fetches count as the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3s OR ≥5% of duration (whichever is smaller). Three-bucket classification: `partial` < 30%, `sampled` 3080%, `complete` > 80%. Emits at most one event per session via `IPlayEventSink`. Deliberately free of player, HTTP, and JS dependencies for testability.
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce so repeated copies of the same link in a session count as one share. Sends via `sendBeacon`. No `anonId` in wave 16.1.
- **Client — `BeaconInterop`** (`DeepDrftPublic.Client/Services/BeaconInterop.cs`): `navigator.sendBeacon` JS interop wrapper + page-unload handler that flushes any pending play event when the page is torn down.
- **Public proxy — `EventProxyController`** (`DeepDrftPublic/Controllers/EventProxyController.cs`): proxies `POST api/event/play` and `POST api/event/share` to DeepDrftAPI. Buffers and relays the small JSON body verbatim; forwards `X-Forwarded-For` for per-IP rate limiting on the API side. Opts out of antiforgery (`[IgnoreAntiforgeryToken]`) — `sendBeacon` cannot attach tokens.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `POST api/event/play` and `POST api/event/share`, unauthenticated, rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, registered in `Program.cs`). Returns `202 Accepted` (fire-and-forget contract). Payload-validates the track key and enum values; delegates writes to `IEventService`.
- **API — rate limiter** (`DeepDrftAPI/Program.cs`): `AddRateLimiter` + `"events"` fixed-window policy keyed on `Connection.RemoteIpAddress`; `UseForwardedHeaders` in production resolves the XFF chain into the real client IP. `UseRateLimiter()` added to the middleware pipeline.
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): append-only writes to `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository stamps the release id.
- **Data — `EventManager` / `IEventService`** (`DeepDrftData/EventManager.cs`): `IEventService` boundary (`RecordPlay`, `RecordShare`); `EventManager` wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs` alongside the existing track and release domain services.
- **Migration `20260619155610_AddPlayShareTelemetry`**: adds `play_event`, `share_event`, and `play_counter` tables. **Authored but not yet applied** (Daniel-gated).
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.3 — Unique-listener `anonId` layer (landed 2026-06-19)
**Landed:** 2026-06-19 on dev (merge `297805b`). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the wave 16.1 migration.
- **What:** The unique-listener `anonId` seam end-to-end — the "last metric layer" of the Phase 16 substrate. Client mints a first-party `localStorage` GUID on first visit, threads it onto play and share beacon payloads (omitted when null), server accepts and length-clamps it (reject-not-truncate, ≤64 chars), persists it to the reserved nullable `anon_id` columns, and exposes all-time distinct-listener aggregation. The distinct-count capability is in place but not yet surfaced on any read surface (16.5 consumes it). Privacy-notice copy deliberately not authored (Daniel-gated).
- **Why:** The anonymous unique-listener metric (D5 / D3) is the final substrate wave before the home Plays card can be lit (16.5). It was sequenced last of the metric layers because it is the lowest-priority metric and carries no dependency — the event log captures `anon_id` on the same rows 16.1 already writes; 16.3 simply lights the seam that was reserved but unused.
- **Shape:**
- **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`.
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/session/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
- **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically).
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): three new distinct-count queries (already in the repository as of 16.3): `CountDistinctListenersAsync()` (site-wide, nulls excluded), `CountDistinctListenersForTrackAsync(trackEntryKey)` (per-track), `CountDistinctListenersForReleaseAsync(releaseId)` (per-release, uses the stamped `release_id` on the play event row — D4 attribution).
- **Data — `IEventService` / `EventManager`** (`DeepDrftData/EventManager.cs`): three new members exposing the distinct-count capability: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)` — each returns `ResultContainer<int>`. No read surface or card consumes them yet (16.5).
- **No migration** — the `anon_id varchar(64)` columns on `play_event` and `share_event` and their covering indexes (`IX_play_event_anon_id`, `IX_share_event_anon_id`) were already created by `20260619155610_AddPlayShareTelemetry` (wave 16.1). Wave 16.3 only wires the client seam and adds the server-side aggregation queries.
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.2 — Completion-bucket classification + shares (landed by absorption into 16.1)
**Landed:** absorbed into wave 16.1 (2026-06-19). All §4.1 deliverables shipped inside the foundation wave.
- **What:** Three-bucket completion classification correct and exhaustive end to end, and share-channel split. Because these were structurally inseparable from the foundation (the tracker, payload, log table, and rollup all required the bucket column set from day one), they landed together with 16.1 rather than as a follow-on wave.
- **Why:** The §4.2 spec listed bucket classification and share-channel split as wave 16.2 items, but the implementation showed they could not be cleanly deferred — the `play_counter` rollup columns are per-bucket by design (D6), and the share `channel` discriminator is a single non-null column on the `share_event` table. Building the log without them would have required a migration to add them in 16.2 anyway.
- **Shape:**
- **`PlayBucket` enum** (`DeepDrftModels.Enums`): `Partial` (< 30%), `Sampled` (3080%), `Complete` (> 80%) — exhaustive, non-overlapping. D1 resolved.
- **`PlayCounter` rollup columns** (`DeepDrftModels.Entities.PlayCounter`): `PartialCount`, `SampledCount`, `CompleteCount` (each `long`), `TotalPlays` (computed `long` sum). `BumpCounterAsync` in `EventRepository` switches on the bucket to increment the correct column in the same transaction as the event append.
- **API-boundary bucket validation** (`EventController`): `Enum.IsDefined(payload.Bucket)` guard — an undefined bucket value returns `400 Bad Request` before the write reaches the repository.
- **`ShareChannel` enum** (`DeepDrftModels.Enums`): `Link` / `Embed` on `ShareEvent.Channel`. `ShareTracker` passes the channel through from the `SharePopover` clipboard action; `EventController` validates it is a defined `ShareChannel` value.
- **Deferred:** optional `share_count` rollup column on `play_counter` (per-track share count in the rollup table) — not built. Shares are not on the home-card hot path; per-target share reads are speculative wave 16.4 work.
---
## Home Hero Stats — Live data wiring (landed 2026-06-18)
**Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`).
- **What:** Replaced the hard-coded placeholder figures in the public home hero stat row (`NowPlayingStats`) with real SQL-backed aggregates. Resolves the "Real stat-row numbers" deferred item from Phase 0 §0.3.
- **Why:** The stat row ("47+ / 2 / ∞") was intentionally hard-coded at Phase 0 with a TODO; the data model now has enough shape (releases, medium discriminator, trackrelease join) to serve real numbers in a single efficient query.
- **Shape:**
- **New SQL column:** `DurationSeconds` (`double?`, column `duration_seconds`) on `TrackEntity` and `TrackDto`. Populated at upload via the existing dual-database add flow (`TrackContentService` extracts duration from vault audio; `UnifiedTrackService` persists it to SQL). Migration `20260618155002_AddTrackDuration`. Configured in `TrackConfiguration`.
- **New aggregate query:** `TrackRepository.GetHomeStatsAsync``HomeStatsDto` (new DTO in `DeepDrftModels/DTOs/`). Returns cut track count, per-`ReleaseType` cut release counts (zero-count types suppressed), mix release count, and total mix runtime seconds (null durations counted as 0; tracks under soft-deleted releases excluded). Surfaced via `ITrackService.GetHomeStats` on `TrackManager`.
- **New API endpoints:** `GET api/stats/home` (`StatsController`, unauthenticated; returns `HomeStatsDto` bare) and `POST api/track/duration/backfill` (ApiKey-gated; one-time backfill of `DurationSeconds` for pre-existing rows from vault audio, delegated to `UnifiedTrackService.BackfillDurationsAsync`).
- **New public proxy:** `StatsProxyController` in `DeepDrftPublic` mirrors `ReleaseProxyController`; forwards `GET api/stats/home` from the browser to DeepDrftAPI.
- **New client surface:** `StatsClient` (`Clients/`, named `"DeepDrft.API"` client) + `IStatsDataService` / `StatsClientDataService` (`Services/`) registered scoped in `Startup.ConfigureDomainServices`. `RuntimeFormat` static helper (`Helpers/`) converts seconds to `hh:mm`.
- **`NowPlayingStats.razor`:** now renders live data — Studio Cuts card (cut track count + zero-suppressed Single/EP/Album breakdown), Mixes card (`MixReleaseCount` "Sets" + `hh:mm` runtime), Plays card (static "XXX / Coming Soon" odometer placeholder). Uses `PersistentComponentState` to bridge the SSR prerender fetch across the WASM seam (only persists on a successful load).
---
## Phase 12 — About Page (public site editorial) (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** A real About page for the public site (`/about`), built entirely in the **Home page's existing visual language** — no new look. Three movements — **the People**, **the Process**, **the Product** — with ethos / pathos / logos woven through the prose as registers, not labelled blocks. The strategic frame (Daniel): the site is *presentation and proof of effort* — evidence that real people are pushing the classic club sound forward; the About page is where that claim is made explicit. Built as `DeepDrftPublic.Client/Pages/About.razor` + scoped `About.razor.css`; registered in the nav index (`DeepDrftPublic.Client/Layout/Pages.cs`). Images served statically from `DeepDrftPublic/wwwroot/img/`; image slots and Khabran's bio degrade gracefully until final assets/copy land.
- **Why:** This is its own phase, not a graft onto Phase 11: Phase 11 was structural (release-cardinal browse, queue, GUID handles), whereas this is a net-new **editorial** surface. The page reuses Home's section primitives wholesale (`.hero`, `.section-divider`, two-column `.section`, dark `.section-dark` feature band, `.section-split`, `.cta-banner`, `ParallaxImage` full-bleed bands) — no new visual language introduced; the only candidate new styling is two member-bio cards, assembled from existing type tokens. Full spec: `product-notes/about-page.md`.
- **Shape:** `DeepDrftPublic.Client/Pages/About.razor` (new; `@page "/about"`; three-movement editorial page using Home section primitives); `DeepDrftPublic.Client/Pages/About.razor.css` (new; scoped styles — Home section primitives currently re-declared here rather than shared globally, a known follow-up); `DeepDrftPublic.Client/Layout/Pages.cs` (nav index registration added). Static images from `DeepDrftPublic/wwwroot/img/`.
**Voice constraint (hard):** smart, serious, no AI-isms — underground Detroit/Midwest deep-club-house heritage carried to Charleston. All body prose remains DRAFT pending Daniel's approval — section headers and UI labels are set; any sentence/paragraph of site copy is a placeholder until Daniel passes it.
**Open follow-ups:** (1) ~~Final photo files for the five image slots~~ Photo slots largely resolved: bio portraits (Khabran + Daniel) now wired as circular framed portraits with a bw→colour hover crossfade (`dd-khabran`, `dd-daniel`); Process hands-on-gear figure now uses bespoke `dd-mixer-2`; the Liner Notes redesign removed the separate full-bleed atmosphere and closing-band slots, so the duo hero (`dd-duo-2`) and the hands-on-gear inset are the only full-bleed image positions remaining and both carry bespoke assets. Home page "Our Origin" split swapped the retired `kp-shoulder` for `dd-pedals`. (2) ~~Khabran's bio text still open~~ Khabran's bio is now wired (three paragraphs; bio-body render updated to emit each paragraph as its own `<p class="bio-body">` — Daniel's single-paragraph bio is unaffected). (3) Optional promotion of the duplicated Home section primitives from `About.razor.css` to a shared global stylesheet. (4) ~~Whether CUTS/SESSIONS/MIXES are explained on the page~~ Resolved by the Liner Notes redesign: the triptych renders as a stacked editorial definition list (see Redesign addendum below).
**Redesign — Liner Notes editorial treatment (2026-06-17):** The page was rebuilt from the Home section primitives approach into a distinct **"Liner Notes"** editorial layout. Structure and copy (three-movement People / Process / Product) are unchanged; the visual treatment is entirely new. Key elements: numbered left rail (oversized Bodoni 01/02/03 movement numerals + continuous vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking left into the margin, hand-authored SVG waveform movement dividers (a self-contained decorative motif, not the live `WaveformVisualizer` component). The CUTS/SESSIONS/MIXES triptych is now a stacked editorial definition list rather than Home's medium-card image grid. Active-movement highlight on the left rail is progressive enhancement via a new `DeepDrftPublic/Interop/about/about-rail.ts` IntersectionObserver interop (compiled output gitignored). Superseded Home section primitives were removed from `About.razor.css`; the global stylesheet was untouched. Design authority: `product-notes/about-page-distinction.md` (Direction 1).
---
## Phase 15 — Visualizer Controls Enhancements (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** A presentation and interaction rework of the waveform visualizer control surface — the eight-RadialKnob panel (Phase 12) hosted by `WaveformVisualizerControlPopover`. Not a renderer change: the WebGL2 visualizer, the eight continuous dial values + their defaults, and the `Changed`-event bridge seam are unchanged. The phase reworks how the controls are reached and presented, adds two on/off toggles (lava, waveform), and gives the panel a deterministic, sectioned layout that encodes the visualizer's composition (lava field + waveform ribbon, optionally overlaid).
Four tracks shipped as a single bundled PR (`15.A → {15.B, 15.C} → 15.D`):
- **15.A — State booleans + bridge wiring.** Two new `WaveformVisualizerControlState` booleans: `LavaEnabled` and `WaveformEnabled` (both default `true`). `WaveformVisualizer.ts` gained a genuine per-subsystem draw-skip: when a subsystem is "off" it is not drawn, contributes no collisions, and incurs no render cost (not dimmed). The bridge pushes the new booleans on `Changed` alongside the eight existing dials. The per-subsystem draw-skip seam was built as part of this track (it did not exist prior).
- **15.B — Screen-centered tinted-modal primitive + NowPlayingCard chrome.** `WaveformVisualizerControlPopover` changed from an anchored `MudPopover` to a screen-centered, tinted modal `MudOverlay` (`DarkBackground`, `Modal="true"`). The `AnchorOrigin`/`TransformOrigin` parameters were dropped — a centered modal has no anchor. Panel chrome follows the NowPlayingCard look: square corners, lighter-navy ground, thin light border. Chrome classes stay in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). Tint opacity resolves from a single `--deepdrft-modal-scrim-alpha` token. Knob-drag safety is preserved: `RadialKnob` mounts its own `position:fixed` capture div above the scrim while dragging, so releasing outside the panel does not close the modal.
- **15.C — Deterministic three-row layout + toggles + scroll slider.** The flat eight-knob grid replaced by a three-row sectioned layout: **Row 1 (MODE, always visible):** two lamp toggles (lava / waveform) left-aligned + collisions knob (only when both subsystems on) + color knob pinned far-right. **Row 2 (LAVA, visible only when lava on):** "LAVA:" label + Gravity / Heat / FluidAmount / FluidViscosity knobs. **Row 3 (WAVE, visible only when waveform on):** "WAVE:" label + scroll/zoom `MudSlider` (bound to `ScrollSpeed` alone) + width knob pinned far-right. The lamp toggles use the `DDIcons.LavaLamp` / `DDIcons.LavaLampFilled` glyph (lit = on, unlit = off) and are green (`Color.Primary`) because they are interactive.
- **15.D — Tooltips + light icon colour.** Each control received a playful, non-technical `MudTooltip`. Knob caption icons and section labels changed to light (`Color.Default` / CSS light token) per the resolved colour principle: green = interactive elements (toggles, knob arcs/pointers, scroll slider); light = static/decorative elements (section labels, caption icons).
- **Why:** The eight-knob flat grid gave the user no signal about which knobs drive the lava vs. the waveform, and neither subsystem could be turned off independently. The new layout sections controls by subsystem, making "lava only" / "waveform only" first-class operating modes. The screen-centering solves the anchored-popover problem: `MudPopover` positions off its trigger's bounding rect — wrong for a control panel that should read as centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient, NowPlaying corner).
- **Shape:** `DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor` — three-row layout replacing the flat eight-knob grid; two `ToggleLava`/`ToggleWaveform` handlers; conditional row visibility; `MudSlider` for scroll speed. `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor``MudPopover` replaced by `MudOverlay` (centered, `DarkBackground`, `Modal`); `AnchorOrigin`/`TransformOrigin` parameters removed. `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — two new boolean properties (`LavaEnabled`, `WaveformEnabled`) and matching `DefaultLavaEnabled`/`DefaultWaveformEnabled` consts (both `true`). `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — per-subsystem draw-skip seam (lava physics + blob uploads skipped when lava off; ribbon SDF + collision boundary dropped when waveform off). `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css``--deepdrft-modal-scrim-alpha` token; `.waveform-visualizer-control-overlay` centering; `.waveform-visualizer-control-modal` panel chrome (square corners, lighter-navy, thin border); row/section layout classes (`wvc-row`, `wvc-row-mode`, `wvc-row-section`, `wvc-row-wave`, `wvc-section-label`, `wvc-toggle`, `wvc-slider`). Full design, layout contract, primitive rationale, tooltip copy, and acceptance: `product-notes/phase-15-visualizer-controls-enhancements.md`.
**Post-landing fixes (2026-06-17):** Seven defects found during smoke-testing were remediated in a follow-up round on dev: (1) new `--deepdrft-panel-ground` CSS token so the blue slider reads against the panel background; (2) drag-scrollbar removed + body-scroll locked while the modal is open; (3) knob caption icons forced light so lamp toggles stay green; (4) WAVE-row slider vertically centered; (5) **site-wide `RadialKnob` pointer-capture fix** — drag no longer sticks when the cursor leaves the browser window, implemented via real `setPointerCapture` / `releasePointerCapture` (benefits every `RadialKnob` on the site, not just this panel); (6) modal scrim alpha softened (0.3 → 0.15); (7) modal overlay z-index raised above the header and player-dock footer. Fix #5 introduced a **new TypeScript interop module in `DeepDrftShared.Client`**: `Interop/knob/knob.ts` (exports `capturePointer`/`releasePointer`), compiled to `wwwroot/js/knob/knob.js` via `Microsoft.TypeScript.MSBuild`, lazy-imported by `RadialKnob.razor` as `_content/DeepDrftShared.Client/js/knob/knob.js` — following the existing `parallax.ts` precedent in the same RCL.
**Polish round 2 (2026-06-17):** Five further UI changes from Daniel's second review: (1) panel ground darkened further (`--deepdrft-panel-ground` `#1e2028``#1a1c22`); (2) **WAVE-row scroll/zoom control reverted from `MudSlider` back to a `RadialKnob`** — Daniel's explicit call, reversing the §8 slider decision; the scroll control is now a knob like the other dials; (3) **waveform toggle given its own distinct icon** — new `DDIcons.Waveform`/`WaveformFilled` six-bar sound-wave glyph, so the waveform toggle and lava toggle each have a unique visual identity (lava toggle keeps the lamp); (4) **strong active-state styling on both toggles** — green-accent filled chip + ring when ON, dimmed when OFF, making subsystem state unmistakable at a glance; (5) `WaveformVisualizerControlPopover.razor` in-source comment refreshed to describe the `setPointerCapture` mechanism.
---
## Phase 14 — CMS Releases Consolidation (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** Retired the CMS `/tracks` list view and consolidated all release browsing into a new standalone **`/releases`** page (`DeepDrftManager/Components/Pages/Tracks/Releases.razor`). The TRACKS|RELEASES `BrowseMode` toggle is gone. The `/releases` layout is: bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid. The unique per-track waveform-status columns (Profile / High-res, with per-row generate buttons) and the per-track info tooltip (EntryKey + OriginalFileName) now live in `CmsAlbumBrowser`'s expanded child-row track table; page-level bulk runs and per-row generates share a refresh bridge (`InvalidateWaveformStatusAsync` + `OnWaveformGenerated` EventCallback wired through each medium container). The `/catalogue` dashboard cards changed from Tracks / Releases / Genres to **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; `/tracks/genres` was removed. Operational sub-routes (`/tracks/upload`, edit routes, `/tracks/mixes`, `/tracks/sessions`, etc.) stayed at `/tracks/*`. `ICmsTrackService.GetGenreSummariesAsync` removed (dead interface member). `GetTrackCountAsync` intentionally retained — planned for the public-site NowPlayingStats feature.
- **Why:** The `/tracks` page mixed a list view and a releases browser behind a toggle (`BrowseMode`), and the waveform-status columns cluttered a per-track list that had no natural home once releases became the cardinal browse unit. Consolidating into a dedicated `/releases` page with a medium tab strip matches the release-medium mental model established in Phase 9 and makes waveform management a subordinate detail of the release's expanded track table rather than a top-level grid column. Retiring genre browse removes a dead-end CMS surface (genre is a filter, not a first-class browse dimension for the admin).
- **Shape:** New: `DeepDrftManager/Components/Pages/Tracks/Releases.razor` (`@page "/releases"` + alias routes for `/tracks`, `/tracks/albums`, `/tracks/archive`). Deleted: `TrackList.razor`, `CmsTrackGrid.razor` (+ `.css`), `CmsGenreBrowser.razor` (+ `.css`), `Services/CmsTrackBrowserViewModel.cs` (+ its DI registration in `Program.cs`). Changed: `Index.razor` dashboard cards updated to CUTS / SESSIONS / MIXES deep-linking to `/releases?medium=<medium>`; `CmsAlbumBrowser` expanded child-row track table gains waveform-status columns + info tooltip + `OnWaveformGenerated` EventCallback; `ICmsTrackService` / `CmsTrackService``GetGenreSummariesAsync` removed.
---
## Phase 13 — CMS Public Landing (landed 2026-06-17)
**Landed:** 2026-06-17 on dev.
- **What:** Gave `DeepDrftManager` (the CMS) a true public face: an unauthenticated splash at `/` with DeepDrft branding and a single **Login** CTA; authenticated admins are redirected past it to the catalogue. Previously `/` was the `[Authorize]`-gated catalogue dashboard, so an anonymous hit fell straight through to the login form with no front door. Pattern borrowed from the `MainHomeLayout` / `Home.razor` idiom (dedicated public layout + `HierarchicalRoleAuthorizeView` redirect-the-authed-user), branded to the DeepDrft navy/green/off-white identity (`DeepDrftPalettes.Cms`). Additive — the admin experience is intact; only the catalogue's route moved. Routing decision: **Option A — splash owns `/`, catalogue moves to `/catalogue`** (Options B and C were weighed and rejected). New files: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, `CmsHomeLayout`) wraps its body in `<HierarchicalRoleAuthorizeView>`: `Authorized``<RedirectToCatalogue />`; `NotAuthorized` → hero (`img/cms-hero.png`) + Login CTA (returnUrl → `/catalogue`). `Components/Layout/CmsHomeLayout.razor` — lean public layout (`DeepDrftPalettes.Cms` theme, "Deep Drft — Admin" AppBar, centered narrow `MudContainer`, `MudPopoverProvider` only). `Components/RedirectToCatalogue.razor` — inline `NavigationManager.NavigateTo("/catalogue")` redirect, mirroring `RedirectToAccessDenied`. Changed: `Index.razor` route `@page "/"``@page "/catalogue"`; `CmsLayout.razor` "Back to site" home button `Href` and tooltip updated to `/catalogue` / "Catalogue". AppBar wording resolved: "Deep Drft — Admin" in the bar, "Deep Drft" as the hero title.
- **Why:** An anonymous visitor hitting the CMS root landed directly on the AuthBlocks login form with no DeepDrft context, branding, or explanation. The splash provides a proper front door while keeping the admin surface fully intact.
- **Shape:** `DeepDrftManager/Components/Pages/Home.razor` (new); `DeepDrftManager/Components/Layout/CmsHomeLayout.razor` (new); `DeepDrftManager/Components/RedirectToCatalogue.razor` (new); `DeepDrftManager/Components/Pages/Index.razor` (route changed to `/catalogue`); `DeepDrftManager/Components/Layout/CmsLayout.razor` (home-button href + tooltip updated). Hero asset: `DeepDrftManager/wwwroot/img/cms-hero.png` (Daniel-supplied; page compiles and renders without it). Full spec: `product-notes/cms-public-landing.md`.
---
## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (all tracks landed 2026-06-17)
**Landed:** 2026-06-17 on dev. Six tracks (12.A, 12.B1, 12.B2, 12.E, 12.C, 12.D) plus a bridge live-track fix, all merged.
- **What:** Took the landed Mix WebGL2 lava visualizer (Phase 10 reframe) and made it the one track-cardinal visualizer — serving Mix detail, all Release Detail pages, and the home-page NowPlaying card — rendering the waveform of whatever track is currently playing/selected. Two deliverables: (1) the generalized engine serving three hosting modes, (2) the NowPlayingHero rewire. Full design, extraction analysis, per-track model, Direction B compute, wave decomposition: `product-notes/phase-12-waveform-visualizer-generalization.md`.
- **12.A — Rename to the abstraction.** `MixWaveformVisualizer``WaveformVisualizer`, `MixVisualizerControls``WaveformVisualizerControls`, `MixVisualizerControlState``WaveformVisualizerControlState`, `MixZoomMapping``WaveformZoomMapping`, `MixVisualizer.ts``WaveformVisualizer.ts`. Mechanical rename across the five C#/Razor files + TS module + import path + DI registration. No behavior change; Mix detail identical after.
- **12.B1 — Generalize high-res compute to every track + backfill (Direction B).** `MixWaveformResolution``WaveformResolution`. Vault `mix-waveforms``track-waveforms` (`VaultConstants.TrackWaveforms`), keyed per-track by `EntryKey`. New `WaveformProfileService.ComputeAndStoreHighResAsync` is the shared compute seam — upload path, CMS generate action, and Mix trigger all funnel through it. `UnifiedTrackService.UploadAsync` now computes the high-res datum for every new track. CMS generate action generalized to any track; a re-runnable "backfill high-res" batch action added in the CMS `TrackList`. `WaveformStatusDto.HasHighRes` added alongside the existing `HasProfile`. Backfill is Daniel-gated (CMS batch action; fetch 404s gracefully for not-yet-backfilled tracks).
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal endpoint `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + public proxy; `ITrackDataService.GetTrackWaveform`; bridge resolves the current track's `EntryKey` and re-fetches on track change. Client `GetMixWaveform` read path retired; API-side release waveform endpoint kept as a caller-less legacy delegate. Mix renders the same high-res lava via the track-cardinal fetch.
- **12.E — Popover-hosted control panel.** `WaveformVisualizerControls` became the panel content; new `WaveformVisualizerControlPopover` pairs the lava-lamp icon with the panel as overlay content (`MudPopover`). Panel styled to the NowPlaying Hero look from `deepdrft-tokens.css` (no hardcoded hex). A `PanelChrome` flag scopes panel chrome to the popover mount. One popover placed by the lava-lamp icon on every host — full parity across Mix, Cut, Session, and NowPlaying card.
- **Bridge live-track fix.** The visualizer now follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`), not the fixed host `TrackId`.
- **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B).** New optional `Ambient` slot on `ReleaseDetailScaffold` (full-bleed layer behind content; absent slot = no regression). Cut mounts the ambient visualizer + the lava-lamp icon → popover. Session mounts the engine directly behind its hero (it doesn't compose the scaffold) + the popover. Mix swapped its inline controls bar for the lava-lamp icon → popover, keeping its own full-bleed mode-A mount.
- **12.D — NowPlayingHero rewire (mode C).** `NowPlayingCard` replaced the 20 synthetic CSS bars with a contained `<WaveformVisualizer>` driven by the live cascaded player, pointed at the current track. Added a `Fill` container-sizing mode (CSS-only, defaults off). Placed the lava-lamp icon → popover on the card for full parity. Visualizer runs at-rest on the home page even before playback (deliberate; perf tuning deferred).
- **Why:** The landed Mix visualizer was structurally track-cardinal below the surface (bridge keyed on `TrackId`; renderer a pure function of a loudness datum + duration) but named `Mix*` throughout and restricted to Mix-only data. "Generalize" was a rename + per-track high-res compute extension, not a rebuild. Direction B (high-res for all media) was chosen over the cheaper 512-bucket-fallback Direction A to deliver uniform waveform quality. Controls moved from per-page inline knob bars to a single popover-hosted panel to achieve zero-cost placement on any host including the small NowPlaying card.
- **Shape:** `DeepDrftPublic.Client/Controls/`: `WaveformVisualizer.razor` (+ `.razor.cs`, `.razor.css`) — renamed engine, added `[Parameter] bool Fill`; `WaveformVisualizerControls.razor` — renamed, now panel content with `PanelChrome` flag; `WaveformVisualizerControlPopover.razor` — new, lava-lamp icon + `MudPopover` wrapping the panel; `WaveformZoomMapping.cs` — renamed; `ReleaseDetailScaffold.razor` (+ `.razor.cs`) — new optional `Ambient` `RenderFragment` slot; `NowPlayingCard.razor` — synthetic bars replaced, `<WaveformVisualizer Fill="true">` + `<WaveformVisualizerControlPopover>`. `DeepDrftPublic.Client/Services/`: `WaveformVisualizerControlState.cs` — renamed. `DeepDrftPublic.Client/Pages/`: `CutDetail.razor` — mounts ambient visualizer + popover; `SessionDetail.razor` — mounts engine + popover directly; `MixDetail.razor` — swaps inline controls bar for popover. `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — renamed TS module. `DeepDrftContent/Processors/`: `WaveformResolution.cs` — renamed; `WaveformProfileService.cs``ComputeAndStoreHighResAsync` added, medium-neutral. `DeepDrftContent/Constants/VaultConstants.cs``TrackWaveforms = "track-waveforms"`. `DeepDrftAPI/Controllers/TrackController.cs``GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + `POST api/track/{trackId}/waveform/high-res` (ApiKey, generalized generate); `WaveformStatusDto.HasHighRes` populated. `DeepDrftAPI/Services/UnifiedTrackService.cs``UploadAsync` now calls `ComputeAndStoreHighResAsync` for every new track. `DeepDrftPublic/Controllers/TrackProxyController.cs` — proxy for the new high-res endpoint.
---
## Phase 10 — Mix Visualizer Reframe: Waves R1R4 (Lava tuning + eight-knob controls)
**Landed:** 2026-06-17 on dev.
- **What:** A major reframe of the Mix visualizer's effects, controls, and color model, built on the landed WebGL2 Phase 10 renderer infrastructure. Four waves:
- **Wave R1** — removed the static noise/frost texture (Daniel: "makes the screen look dirty"); implemented dynamic footer-height clip so the lava stops cleanly above the player bar; redrawn `DDIcons.LavaLamp` to the classic 1970s silhouette (wide truncated-cone base, bulbous→roundedly-pointed teardrop glass body, small cone cap — navy fluid + moss blobs, body `currentColor`).
- **Wave R2** — CPU-side per-frame physics step (~1632 Lagrangian wax blobs: position/velocity/temperature/radius), uploaded as uniforms each frame; `smin` SDF metaball render producing a **flat, coalescing fluid** (not blobs with radial hotspots); energy-coupled dynamics (high heat → many small turbulent bubbles; low heat → fewer large calm masses); 2D elastic collision on both blob↔waveform and blob↔blob pairs; collision strength knob sweeping from genuine soft mush to a high-elasticity upward-and-outward throw; waveform collision always on regardless of heat. Loudness profile smoothed with a **~15 ms envelope-follower at preprocessing only** (`RmsLoudnessAlgorithm.cs`); there is no decode-time smoothing (`smoothDatum` was removed). Existing vault mixes gain the smoothing only after server-side reprocessing — they do not benefit automatically. Ribbon rendered with smootherstep sinusoid reconstruction.
- **Wave R3** — replaced HSL `mixHsl`/`vivify` color with OKLab interpolation (structural fix for the cyan excursion artifact); three combined gradient motions: (1) A/B anchor rotation among three theme colors at the gradient-rotation-speed rate; (2) per-segment sinusoidal variation keyed to mix-time so colors travel with the segment as it scrolls; (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). Static noise texture removed. One source of truth (`DeepDrftPalettes`), no hardcoded hexes.
- **Wave R4**`MixVisualizerControlState` widened from four properties to **eight**: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, **`FluidAmount`** (replaces the former `BlobDensity`), **`FluidViscosity`** (new — cohesion / coalescence control, the second half of the bubbles split), `CollisionStrength`, `WaveformWidth`. `MixVisualizerControls` now renders **eight** `RadialKnob`s; the `Visible` parameter `@if`-gates the knob band while the container holds a reserved min-height so content below never pops when the lamp toggles. Scroll-speed knob range tuned to 60110% band; gravity 075%; heat +20% at top; width default 50% / range 1095%. The scaffold's `TopRowCenter` slot (added in the prior reframe) carries the controls in-flow between the back link and lava-lamp toggle.
- **Why:** Daniel tested the Phase 10 effects end-to-end and rejected the visual result: lava read as "giant disconnected circles," colors drifted to cyan (HSL arc artifact), waveform and lava read as two unrelated layers. The diagnosis was that these were structural failures of the prior model (too few scripted blobs with no physics; HSL hue-arc through cyan), not tuning misses. The reframe replaced the model with CPU-physics wax blobs + OKLab gradients, fixing both root causes.
- **Shape:** `DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs` — ~15 ms envelope-follower smoothing added at preprocessing (server-side only; no decode-time smoothing). `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — smootherstep sinusoid ribbon reconstruction; wax-blob physics loop; OKLab gradient; footer-clip; noise texture removed. `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — widened to eight properties (`FluidAmount` + `FluidViscosity` replace `BlobDensity`; `WaveformWidth` range/default updated). `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor` — eight `RadialKnob`s, `Visible` parameter gates knob band via `@if` while container holds reserved height. `DeepDrftShared.Client/Common/DDIcons.cs``LavaLamp` glyph redrawn. Full design, acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`.
---
## Phase 11 — Public Site Enhancements (complete — all tracks 11.A11.H landed 2026-06-16)
### 11.H — release `EntryKey` identifiers (terminal public-site wave)
**Landed:** 2026-06-16 on dev.
- **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index.
- **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11.
- **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied.
---
### 11.D — Archive filters in the URL
**Landed:** 2026-06-16 on dev.
- **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase.
- **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines).
- **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`.
---
### 11.E — release-level Share
**Landed:** 2026-06-16 on dev.
- **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched.
- **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic.
- **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to `<SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" />`.
---
### 11.C — retire track-cardinal stack + normalize release cards
**Landed:** 2026-06-16 on dev.
- **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet.
- **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places.
- **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func<ReleaseDto, string>? HrefResolver { get; set; }` and `[Parameter] public Func<ReleaseDto, string>? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`.
---
### 11.B — `ReleaseRoutes` resolver + repoint
**Landed:** 2026-06-16 on dev.
- **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C.
- **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting.
- **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id.
---
### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F)
**Landed:** 2026-06-16 on dev.
- **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`.
- **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only.
- **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`.
---
### 11.A — `/cuts/{id}` album-detail page
**Landed:** 2026-06-16 on dev.
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
---
### 11.F — play-queue `IQueueService`
**Landed:** 2026-06-16 on dev.
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable<TrackDto>, int)` loads an ordered track list and starts playback.
---
### 11.G — release Description schema slice
**Landed:** 2026-06-16 on dev.
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
---
## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only)
**Landed:** 2026-06-16 on dev.
- **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `<div class="release-hero-img">` (no `MudPaper`).
- **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack.
- **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by `<ReleaseHeroOverlay ... />`; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `<ReleaseHeroOverlay Class="mix-hero" ... />` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css``mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor``bool ShowHeader = true` gate added around the default header region.
Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`.
---
## CMS Grid Refinements
### `CmsAlbumBrowser` special-action column promotion
**Landed:** 2026-06-15 on branch `cms-special-action-columns`.
Follow-on refinement of 8.C: the `RenderFragment<ReleaseDto>? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell.
- **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; }` (defaulting to `Array.Empty<SpecialActionColumn>()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment<ReleaseDto> Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `<MudTh>` per declared column (between the Tracks header and the Actions header) and one `<MudTd>` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`).
- **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column.
- **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file.
**Completion note:** `CmsAlbumBrowser.razor``RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell)`. `CmsMixBrowser.razor``RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks).
---
## 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 (fully landed: 8.A8.J + 8.L, 8.M, 8.K)
**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); 8.M on 2026-06-14; 8.K on 2026-06-14.
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) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete.
**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.
---
**8.M — Retire the legacy single-track CMS forms**
- **What:** Retire `TrackNew` (`/tracks/new`) and `TrackEdit` (`/tracks/{Id:long}`) as standalone authoring forms in `DeepDrftManager`. Their responsibility is absorbed by `BatchUpload` / `BatchEdit`'s single-track branch.
- **Why:** The legacy forms were a duplicate code surface. Folding their function into the batch forms reduces form surface and removes the addressing-model gap that existed between `TrackEdit` (addressed by track id) and `BatchEdit` (addressed by release title). Daniel's decision: "consolidate the forms and reduce the code surface" (2026-06-13).
- **Shape:** **Option 2 (Daniel's decision):** `BatchEdit` gained a track-addressed route `/tracks/{TrackId:long}/edit` that resolves the track to its parent release via `GetByIdAsync`, loads the release through the existing release-load path, and pre-selects the addressed track's row (`ResolveInitialSelection` matches by `Id`, falls back to row 0). The existing release-title route (`/tracks/album/{AlbumName}/edit`) is untouched. The two legacy components were reduced to thin redirect shims (not hard-deleted, to guard bookmarks): `/tracks/new``/tracks/upload`; `/tracks/{Id}``/tracks/{Id}/edit`. `CmsTrackGrid`'s per-row Edit now targets `/tracks/{id}/edit`. Files changed (6): `BatchEdit.razor`, `BatchUpload.razor`, `CmsTrackGrid.razor`, `SessionFields.razor`, `TrackEdit.razor`, `TrackNew.razor`. No new component, no public-site change, no constructor growth, no `IServiceProvider`.
**Completion note:** `BatchEdit.razor` gained a second `@page` route `/tracks/{TrackId:long}/edit`; `OnInitializedAsync` uses `GetByIdAsync` when `TrackId` is set, resolves the parent release, loads through the existing release-load path, and calls `ResolveInitialSelection` which matches by `Id` (falls back to row 0) to pre-select the addressed track's row. The existing `/tracks/album/{AlbumName}/edit` route and its load path are untouched. `TrackEdit.razor` and `TrackNew.razor` were each reduced to thin redirect shims — `TrackNew` redirects `/tracks/new``/tracks/upload`; `TrackEdit` redirects `/tracks/{Id}``/tracks/{Id}/edit` — preserving inbound bookmarks without keeping dead form logic. `CmsTrackGrid.razor` per-row Edit link updated from `/tracks/{id}` to `/tracks/{id}/edit`. `SessionFields.razor` and `BatchUpload.razor` received minor coordinating edits. Build clean. No automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference — consistent with prior Wave 8 tracks). All acceptance criteria met; legacy `TrackNew`/`TrackEdit` authoring forms retired; track-addressed edit route live on `BatchEdit`.
---
**8.K — Mix Visualizer redesign (post-Phase-9 wave)**
- **What:** Replace the static SVG waveform silhouette on the Mix detail page with a windowed, playback-coupled, bottom-to-top scrolling Canvas 2D animation; simultaneously switch Mix loudness datum capture from a fixed 2048-bucket count to a duration-derived constant-time-resolution scheme. Strictly read-only (no seek seam); theme-aware glassy gradient aesthetic (lava-lamp idiom, MudBlazor palette, live dark-mode responsive).
- **Why:** The static SVG silhouette did not communicate playback progress or the shape of the material at any useful zoom level. Long mixes were under-sampled at fixed 2048 buckets — the visualizer design called for ~333 samples/sec so max-zoom detail is legible. The redesign gives Mix detail pages their signature dynamic visual and makes the waveform datum meaningfully dense.
- **Shape:** Two waves. Wave 1 (datum §F): bucket count becomes `ceil(durationSeconds × 333)`, clamped `[2048, 2_000_000]`; pure helper `MixWaveformResolution.cs` (`BucketCountForDuration`, named constants `SamplesPerSecond`/`MinBucketCount`/`MaxBucketCount`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives the count from `audio.Duration`; fixed `MixWaveformBucketCount = 2048` constant removed. Single high-density datum (not tiered/mipmap — Daniel's decision). Backward-compatible: existing 2048-bucket mixes still render coarsely; re-running the Generate trigger re-captures at new density. Wave 2 (renderer §A/B/C/D/E): `MixWaveformVisualizer` rewritten from static SVG to Canvas 2D scrolling animation driven by a `requestAnimationFrame` loop in new TS interop module `MixVisualizer.ts` (`DeepDrftPublic/Interop/visualizer/`). Guitar-Hero zoom coupling anchored at 0.333 s (1 quarter note @ 180 BPM max-zoom), range 0.333 s → 30 s, default-open 10 s. rAF loop gated on is-playing (idle on pause; one-shot redraws on zoom/theme/datum/resize while idle). Sample↔time mapping uses the DTO's `BucketCount` and the mix duration (sourced from the cascaded player, gated to the mix's `TrackId`) — no fixed-2048 assumption. New `MixZoomMapping.cs` (pure log-scaled zoom↔seconds) and `MixVisualizerZoomState.cs` (scoped, session-persistent, resets on fresh load, registered in `Startup.cs`); `MixDetail.razor` passes `TrackId`. Inert `OnSeek` + two-way `PlaybackPosition` seam dropped; `PlaybackPosition` is one-way input; `ReleaseId` self-fetches the datum. No `@rendermode` override, no constructor growth, no `IServiceProvider`; component CSS scoped.
**Completion note:** Wave 1 landed: `DeepDrftContent/Processors/MixWaveformResolution.cs` (new, pure helper with `BucketCountForDuration`, `SamplesPerSecond = 333`, `MinBucketCount = 2048`, `MaxBucketCount = 2_000_000`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives bucket count from `audio.Duration` via the new helper; fixed `MixWaveformBucketCount = 2048` constant removed. `WaveformProfileDto.BucketCount` now varies per-mix. 8 unit tests in `MixWaveformResolutionTests.cs`. Wave 2 landed: `MixWaveformVisualizer` rewritten as a Canvas 2D scrolling component; `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` (new TS module) owns canvas, datum decode, rAF loop, scroll/zoom/compositing math, and dark-mode responsive theming. `MixZoomMapping.cs` and `MixVisualizerZoomState.cs` (new); zoom state registered as scoped in `Startup.cs`; `MixDetail.razor` passes `TrackId`. Two-way `PlaybackPosition` binding dropped; one-way input only. No automated tests for Wave 2 (DeepDrftTests references DeepDrftContent/DeepDrftData, not DeepDrftPublic.Client — consistent with prior public-site UI waves). Design spec: `product-notes/phase-9-mix-visualizer-redesign.md`. All acceptance criteria met; Wave 8 track 8.K completes the Mix Visualizer redesign and closes Wave 8 in full.
---
### 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
### 8.6 "Music through Every Medium" home page section
+251 -22
View File
@@ -6,15 +6,17 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## 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 (512-bucket player-bar seeker + per-track high-res visualizer datum), and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (session hero-image upload; mix waveform is a caller-less legacy delegate — the track-cardinal `GET api/track/{entryKey}/waveform/high-res` is the live fetch path). 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)
- `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`).
- `Controllers/TrackController.cs`: Seven track endpoints (see below).
- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates streaming vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`). The upload/replace hot path streams audio into the vault via the `ProcessedAudio` plan (Wave 1 OOM fix) and then computes both waveform datums in a single bounded streaming pass via `TryStoreWaveformDatumsAsync` (Wave 2 OOM fix) — neither path buffers the whole audio file in a managed `byte[]`.
- `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).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
@@ -26,16 +28,126 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary) and imag
- 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).
## The endpoint surface (seven endpoints)
## The endpoint surface
### GET api/track/{trackId} (unauthenticated)
Returns the WAV bytes from the `tracks` vault with HTTP Range support.
Streams the track's audio bytes from disk with HTTP Range support. An optional `?format=opus` query parameter selects between the derived Opus artifact and the lossless source.
- **Route parameter `trackId`** (string): the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
- **Range header** (optional): HTTP Range header for byte-range requests (e.g., `Range: bytes=1000-`). Server responds with `206 Partial Content` and streams from the requested offset.
- 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`).
- **Query parameter `format`** (optional): `opus` requests the derived Ogg Opus artifact from the `track-opus` vault when present, falling back to lossless when it is not (C2 — never 404 if any audio exists); omitted or any other value delivers the lossless source in its stored format (WAV/MP3/FLAC). Both arms stream from a seekable disk `FileStream` via `File(stream, contentType, enableRangeProcessing: true)` — no whole-file `byte[]`; `Content-Type` reflects what was actually served (e.g., `audio/ogg` on an Opus hit, the source's real MIME on a lossless request or C2 fallback).
- **Range header** (optional): HTTP Range header for byte-range requests (e.g., `Range: bytes=1000-`). Server responds with `206 Partial Content` and streams from the requested offset. Honoured on both arms (seekable `FileStream` in both cases).
- Returns 200 for full-file requests, 206 for Range requests, 404 if no audio artifact exists for the track, 500 if vault operations fail.
### 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).
- Streams the vault audio in bounded ≤80 KB chunks (no whole-file load) via `WaveformProfileService.ComputeAndStoreProfileStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200.
- 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/{trackId}/waveform/high-res (unauthenticated)
Track-cardinal high-res datum fetch. Returns the per-track duration-derived high-res waveform datum (~333 samples/sec) from the `track-waveforms` vault. This is the live read path for the `WaveformVisualizer` bridge — the release-level mix waveform endpoint is a caller-less legacy delegate.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if no high-res datum is stored (graceful — not-yet-backfilled tracks fall back to no visualizer data). Returns 500 on vault error.
### POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
Server-side trigger: compute and store the per-track high-res datum for any track from its vault audio, keyed by `EntryKey` in the `track-waveforms` vault. Drives the CMS per-row "Generate high-res" action and the CMS batch backfill action. Generalised off Mix-only in Phase 12.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Reads the duration from vault index metadata (no audio body load), then streams the vault audio in bounded chunks via `WaveformProfileService.ComputeAndStoreHighResStreamingAsync`. Tri-state result: `null` = no vault audio → 404; `false` = audio present but not WAV-decodable or vault write failed → 500; `true` = stored → 200.
- Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure.
### 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 flags indicating whether each waveform type is stored. Used by the CMS track list to flag tracks needing waveform computation.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault).
- Returns 200 on success. Returns 500 on query error.
### GET api/track/opus-status ([ApiKeyAuthorize])
Admin Post-Processing view: returns every track with a flag indicating whether it has a complete Opus artifact (both audio AND seek/setup sidecar present in the `track-opus` vault). Used by the CMS to show the Backfill-Opus badge and to poll per-track Post-Processing status after an upload. Mirrors the shape and auth posture of `GET api/track/waveform-status`.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Response**: `List<OpusStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, and `HasOpus` (bool — true only when both the Opus audio entry and the seek/setup sidecar entry are present in `track-opus`; a half-derived track counts as incomplete).
- `HasOpus` is resolved via `TrackFormatResolver.HasOpusAsync` — an **index-only** existence check (`MediaVault.HasIndexEntry` for both entries; no file-body load). The endpoint loops over the whole catalogue, so a body load per track would stream the full library sequentially; the index lookup costs zero disk reads per track.
- Returns 200 on success. Returns 500 on query error.
### POST api/track/duration/backfill ([ApiKeyAuthorize])
Admin backfill: for every track whose `DurationSeconds` SQL column is still null, reads the `AudioBinary.Duration` from the vault and writes it to SQL. Idempotent — a re-run only touches still-null rows; rows that already have a value are skipped.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- No request body.
- Calls `UnifiedTrackService.BackfillDurationsAsync`. Lives on `TrackController` in the literal-route block (before `{trackId}` routes, so the segment is never treated as a trackId).
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
- Returns 200 on success. Returns 500 if the backfill operation fails.
### GET api/track/release/exists ([ApiKeyAuthorize])
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Query parameters**:
- `title` (string, required): the release title to check.
- `artist` (string, required): the artist name to check.
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. 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])
@@ -61,10 +173,14 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- `genre` (string, optional)
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
- `createdByUserId` (long, required): audit trail — who uploaded this track.
- 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.
- 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.
- `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.
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `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 staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic streaming vault write via router — audio is streamed to the vault via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix) → `TrackManager` (SQL persist with `createdByUserId`) → `TryStoreWaveformDatumsAsync` (best-effort: reads duration from vault index metadata, then computes both waveform datums from a single bounded streaming pass over the stored audio — Wave 2 OOM fix). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -75,17 +191,31 @@ 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.
- Returns 200 on success, 404 if track not found, 500 if deletion fails.
### GET api/track/page ([ApiKeyAuthorize])
### POST api/track/{id:long}/replace-audio ([ApiKeyAuthorize])
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser.
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) as `multipart/form-data` and replaces the existing track's vault bytes in place, preserving the track id, `EntryKey`, SQL row (metadata/release/position), and release membership. Both waveform datums (512-bucket seeker profile + high-res visualizer datum) are regenerated after the swap; waveform regen failure is logged and swallowed — it does not fail the replace.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (streams new audio into the vault under the existing `EntryKey` via the `ProcessedAudio` plan, no whole-file buffer — Wave 1 OOM fix; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums from a single bounded streaming pass over the freshly stored audio via `TryStoreWaveformDatumsAsync` (best-effort; a datum failure is logged and swallowed — Wave 2 OOM fix) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
### GET api/track/page (unauthenticated)
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
- **Query parameters**:
- `page` (int, optional, default 1): 1-based page number.
- `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"`, `"TrackNumber"`. Defaults to `Id`.
- `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.
### GET api/track/meta/{id:long} ([ApiKeyAuthorize])
@@ -103,9 +233,18 @@ Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **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).
- 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`.
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
- **Body**: `UpdateTrackMetadataRequest` with fields:
- `TrackName` (string, required)
- `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)
@@ -126,6 +265,93 @@ Returns image bytes from the `images` vault.
- 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`).
## 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/{entryKey} (unauthenticated)
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `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/{entryKey}/mix/waveform (unauthenticated — caller-less legacy delegate)
Legacy endpoint: formerly served the high-res waveform datum for a Mix release from the `mix-waveforms` vault. **No longer called by the client** — the live fetch path is now the track-cardinal `GET api/track/{trackId}/waveform/high-res` (Phase 12). The endpoint is retained in the API but has no active callers. `UnifiedReleaseService.TriggerMixWaveformAsync` now delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` (the same shared seam used by the upload path and the generalized CMS generate action).
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
- **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 the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResStreamingAsync` (streams the vault audio in bounded chunks, no whole-file buffer) — the same shared seam used by the upload path and the generalized CMS generate action. 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.
## The stats endpoints
### GET api/stats/home (unauthenticated)
Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A single read returns everything the three cards need so the client makes one round-trip. Public, same auth posture as `GET api/track/page`.
- **Response**: `HomeStatsDto` with:
- `CutTrackCount` (int): total non-deleted tracks on Cut-medium releases.
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (zero-suppressed server-side).
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0).
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's bucket columns (`PartialCount + SampledCount + CompleteCount`), all-time (Phase 16). Zero until the play-telemetry migration is applied.
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` across all play events, all-time (Phase 16). Zero until the migration is applied.
- `StatsController` injects **both** `ITrackService` (track-domain aggregation — Cuts/Mixes cards) and `IEventService` (event-domain aggregation — Plays card). Neither domain reaches into the other's tables; the controller is the thin composition seam. Track-domain aggregation comes from `TrackRepository.GetHomeStatsAsync` via `ITrackService.GetHomeStats`; play/listener figures come from `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16 wave 16.5). Play/listener reads are **best-effort**: a telemetry failure or not-yet-applied migration leaves those fields at 0 rather than failing the whole endpoint with 500.
- Returns 200 on success. Returns 500 if the track-domain aggregation fails.
## The event endpoints (Phase 16 anonymous telemetry)
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
### POST api/event/play (unauthenticated, rate-limited)
Records an anonymous play event. Client sends the track `EntryKey`, a completion bucket, and an optional `anonId` (wave 16.3); server-side release resolution joins track→release at write time (D4). The `anonId` is length-clamped server-side: whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars returns `400` rather than being truncated (truncation would collide distinct listeners).
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete", "anonId": "..." }` (`anonId` optional — omitted when null).
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
### POST api/event/share (unauthenticated, rate-limited)
Records an anonymous share event (a clipboard write from `SharePopover`).
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed", "anonId": "..." }` (`anonId` optional — omitted when null; same length-clamp as the play endpoint).
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values; `anonId` ≤ 64 chars (reject-not-truncate).
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
@@ -160,7 +386,9 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -183,8 +411,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
## Configuration files
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
- `Logging`: standard ASP.NET structure.
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
+114
View File
@@ -0,0 +1,114 @@
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DeepDrftAPI.Controllers;
/// <summary>
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
/// </summary>
[ApiController]
[Route("api/event")]
[EnableRateLimiting("events")]
public class EventController : ControllerBase
{
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
// payloads are a track key + an enum, well under 1 KB.
private const int MaxBodyBytes = 1024;
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
private const int MaxAnonIdLength = 64;
private readonly IEventService _eventService;
private readonly ILogger<EventController> _logger;
public EventController(IEventService eventService, ILogger<EventController> logger)
{
_eventService = eventService;
_logger = logger;
}
// POST api/event/play (unauthenticated, rate-limited)
[HttpPost("play")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
{
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
// keep the contract obvious and cover the empty-string key the model binder lets through.
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
return BadRequest("trackEntryKey is required");
if (!Enum.IsDefined(payload.Bucket))
return BadRequest("bucket is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
if (!result.Success)
{
// A telemetry failure must never surface to the listener as an error they can act on, but
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
// beacon ignores the status either way.
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// POST api/event/share (unauthenticated, rate-limited)
[HttpPost("share")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(payload.TargetKey))
return BadRequest("targetKey is required");
if (!Enum.IsDefined(payload.TargetType))
return BadRequest("targetType is invalid");
if (!Enum.IsDefined(payload.Channel))
return BadRequest("channel is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
if (!result.Success)
{
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
// over the column width is rejected (400) rather than truncated, since truncation would collide
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
{
if (string.IsNullOrWhiteSpace(raw))
{
anonId = null;
return true;
}
var trimmed = raw.Trim();
if (trimmed.Length > MaxAnonIdLength)
{
anonId = null;
return false;
}
anonId = trimmed;
return true;
}
}
@@ -0,0 +1,191 @@
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/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
// constraint). Declared before the shorter "{entryKey}" route for clarity.
//
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
// release-addressed shape.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, 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: {EntryKey}", entryKey);
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
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: stream the Mix's track audio from the vault, compute a duration-derived
// high-res waveform, store it in the track-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/{entryKey} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
// route so the segmented route resolves first.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
{
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
}
@@ -0,0 +1,59 @@
using DeepDrftData;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StatsController : ControllerBase
{
private readonly ITrackService _sqlTrackService;
private readonly IEventService _eventService;
private readonly ILogger<StatsController> _logger;
public StatsController(
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
{
_sqlTrackService = sqlTrackService;
_eventService = eventService;
_logger = logger;
}
// GET api/stats/home (unauthenticated)
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
// posture as the other public browse reads (GET api/track/page). The figures span two domains:
// the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain
// figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This
// controller is the thin composition seam that assembles both into one HomeStatsDto — neither
// domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read
// failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card.
[HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default)
{
var trackResult = await _sqlTrackService.GetHomeStats(ct);
if (!trackResult.Success || trackResult.Value is null)
{
var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetHome stats failed: {Error}", error);
return StatusCode(500, "Failed to load stats");
}
var stats = trackResult.Value;
var playsResult = await _eventService.GetTotalPlayCount(ct);
if (playsResult is { Success: true })
stats.TotalPlays = playsResult.Value;
else
_logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}",
playsResult.Messages.FirstOrDefault()?.Message);
var listenersResult = await _eventService.GetDistinctListenerCount(ct);
if (listenersResult is { Success: true })
stats.UniqueListeners = listenersResult.Value;
else
_logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}",
listenersResult.Messages.FirstOrDefault()?.Message);
return Ok(stats);
}
}
+479 -46
View File
@@ -5,6 +5,7 @@ using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
@@ -20,6 +21,8 @@ public class TrackController : ControllerBase
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly TrackFormatResolver _formatResolver;
private readonly UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -34,6 +37,8 @@ public class TrackController : ControllerBase
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
TrackFormatResolver formatResolver,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -41,16 +46,57 @@ public class TrackController : ControllerBase
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_formatResolver = formatResolver;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
private string BuildStagingPath(string uploadExtension) =>
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
// must delete it in a finally block; separating path generation from the copy ensures the finally
// guard fires even when CopyToAsync throws before returning.
private async Task StageUploadAsync(
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
{
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// 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}.
// 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")]
public async Task<ActionResult> GetPage(
[FromQuery] int page = 1,
@@ -60,9 +106,10 @@ public class TrackController : ControllerBase
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
[FromQuery] long? releaseId = null,
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 result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
@@ -94,6 +141,37 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
[ApiKeyAuthorize]
[HttpGet("release/exists")]
public async Task<ActionResult> ReleaseExists(
[FromQuery] string? title,
[FromQuery] string? artist,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return BadRequest("title and artist are both required");
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
return StatusCode(500, "Failed to check release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
// GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
@@ -136,8 +214,9 @@ public class TrackController : ControllerBase
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
@@ -156,42 +235,123 @@ public class TrackController : ControllerBase
foreach (var track in tracks.Value)
{
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
HasHighRes = highRes is not null,
});
}
return Ok(status);
}
// GET api/track/opus-status ([ApiKeyAuthorize])
// Admin Post-Processing view (18.6): returns every track with a flag for whether it carries a COMPLETE
// Opus artifact — both the Opus audio AND the seek/setup sidecar present (TrackFormatResolver.HasOpusAsync,
// the same completeness rule the 18.5 Backfill-Opus pass enqueues against; a half-derived track counts as
// missing). Mirrors GET waveform-status exactly: same ApiKey auth, same unpaged whole-catalogue shape, same
// literal-route placement before "{trackId}". The CMS reads it to show the Backfill-Opus "missing N" badge
// and to poll per-track Post-Processing status after an upload.
[ApiKeyAuthorize]
[HttpGet("opus-status")]
public async Task<ActionResult> GetOpusStatus()
{
var tracks = await _sqlTrackService.GetAll();
if (!tracks.Success || tracks.Value is null)
{
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetOpusStatus failed to load tracks: {Error}", error);
return StatusCode(500, "Failed to load tracks");
}
var status = new List<OpusStatusDto>(tracks.Value.Count);
foreach (var track in tracks.Value)
{
status.Add(new OpusStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasOpus = await _formatResolver.HasOpusAsync(track.EntryKey),
});
}
return Ok(status);
}
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
// only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
// (before "{trackId}") so the segment is never treated as a trackId.
[ApiKeyAuthorize]
[HttpPost("duration/backfill")]
public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
{
var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurations failed: {Error}", error);
return StatusCode(500, error);
}
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
}
// POST api/track/opus/backfill ([ApiKeyAuthorize], no body)
// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every track lacking a complete Opus
// artifact (audio + sidecar). Mirrors the duration-backfill posture — enqueue-only and non-blocking, the
// transcodes run on the shared serial worker. Idempotent: a re-run only schedules tracks still missing
// Opus. Returns { enqueued, skipped }. Declared in the literal-route block (before "{trackId}") so the
// "opus/backfill" segment is never treated as a trackId; distinct shape from "{trackId}/opus" (per-track).
[ApiKeyAuthorize]
[HttpPost("opus/backfill")]
public async Task<ActionResult> BackfillOpus(CancellationToken cancellationToken)
{
var result = await _unifiedService.BackfillOpusAsync(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillOpus failed: {Error}", error);
return StatusCode(500, error);
}
return Ok(new { enqueued = result.Value.Enqueued, skipped = result.Value.Skipped });
}
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
// proxies the upload here so it never touches the vault disk path or SQL directly.
// UnifiedTrackService owns the two-database write.
//
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
// not a buffered allocation.
// RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
// hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
// outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
// limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below
// int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
[RequestSizeLimit(2_000_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
[FromForm] IFormFile? audioFile,
[FromForm] string? trackName,
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? description,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
[FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber,
[FromForm] long? releaseId,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
@@ -242,40 +402,64 @@ public class TrackController : ControllerBase
if (!string.IsNullOrWhiteSpace(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;
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
// generate our own path preserving the validated .wav/.mp3/.flac extension.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre,
string.IsNullOrWhiteSpace(description) ? null : description,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
parsedReleaseType,
parsedMedium,
resolvedTrackNumber,
releaseId,
cancellationToken);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
// A cardinality or duplicate-release 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..]);
}
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
}
return StatusCode(500, error);
}
@@ -289,17 +473,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -384,6 +558,7 @@ public class TrackController : ControllerBase
release.Artist = request.Artist;
release.Title = request.Album ?? string.Empty;
release.Genre = request.Genre;
release.Description = request.Description;
release.ReleaseDate = request.ReleaseDate;
// ImagePath is tri-state: null = no change, "" = clear, value = set.
@@ -393,6 +568,20 @@ public class TrackController : ControllerBase
// ReleaseType is non-null on the release; null in the request means "no change".
if (request.ReleaseType is not null)
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);
@@ -431,6 +620,69 @@ public class TrackController : ControllerBase
return StatusCode(500, error);
}
// POST api/track/{id}/replace-audio ([ApiKeyAuthorize])
// Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
// release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
// vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
// streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
// literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
// resolves to the parameterized "{trackId}" GET.
[ApiKeyAuthorize]
[HttpPost("{id:long}/replace-audio")]
[RequestSizeLimit(2_000_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
public async Task<ActionResult> ReplaceAudio(
long id,
[FromForm] IFormFile? audioFile,
CancellationToken cancellationToken)
{
_logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length);
if (audioFile is null || audioFile.Length == 0)
{
return BadRequest("Audio file is required");
}
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
{
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
return Ok();
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to replace audio";
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
{
return NotFound();
}
_logger.LogError("ReplaceAudio failed for id {Id}: {Error}", id, error);
return StatusCode(500, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "ReplaceAudio failed for id {Id}", id);
return StatusCode(500, "Internal server error");
}
finally
{
DeleteStagingFile(stagingPath);
}
}
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
@@ -449,10 +701,28 @@ public class TrackController : ControllerBase
// --- Parameterized routes ---
// GET api/track/{trackId}?format=opus|lossless (unauthenticated)
// Streams the track's audio bytes with HTTP Range support. The optional `format` selector (Phase 18.3)
// picks the delivery rendering: absent or unrecognized ⇒ Lossless (byte-identical to pre-Phase-18 —
// the existing zero-copy disk-stream path, untouched); `opus` ⇒ the derived Ogg Opus 320 artifact
// when present, falling back to lossless when it is not (C2 — never 404/silence). The Opus path streams
// the resolved artifact from a seekable disk FileStream via File(..., enableRangeProcessing: true) —
// no whole-file byte[] — so Range: bytes=X- still yields 206 (load-bearing for streaming + seek),
// matching the lossless disk-stream's range behavior.
[HttpGet("{trackId}")]
public async Task<ActionResult> GetTrack(string trackId)
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] string? format = null)
{
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
_logger.LogInformation("GetTrack called with trackId: {TrackId}, format: {Format}", trackId, format);
// Only `opus` diverges from today's behavior; everything else (null, "lossless", garbage) takes the
// unchanged lossless disk-stream path below, preserving the large-file zero-copy streaming. Routing
// lossless through the resolver would force the whole source (up to ~1 GB) into memory per request —
// a regression the resolver's in-memory byte[] result is fine for Opus (small) but not for lossless.
if (Enum.TryParse<AudioFormat>(format, ignoreCase: true, out var requestedFormat)
&& requestedFormat == AudioFormat.Opus)
{
return await GetTrackOpusAsync(trackId);
}
try
{
@@ -471,7 +741,7 @@ public class TrackController : ControllerBase
}
// Resolve MIME and log before handing the stream to File().
// If anything here throws, the finally block disposes the wrapper
// If anything here throws, the catch block disposes the wrapper
// (and its inner FileStream) so neither leaks. On the success path
// File() takes ownership of the inner stream; ASP.NET Core disposes
// it after the response body is sent. The wrapper is a thin struct
@@ -485,16 +755,15 @@ public class TrackController : ControllerBase
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
streamLength = mediaStream.Stream.Length;
innerStream = mediaStream.Stream;
_logger.LogInformation(
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
trackId, streamLength);
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
_logger.LogInformation(
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
trackId, streamLength);
// enableRangeProcessing: true — seek is served by HTTP Range requests.
// The FileStream is seekable, so ASP.NET Core honours an incoming
// Range header by slicing the file and responding 206 Partial Content.
@@ -507,6 +776,76 @@ public class TrackController : ControllerBase
}
}
// The ?format=opus arm of GetTrack. Resolves the Opus artifact (or the lossless fallback when none
// exists, C2) via TrackFormatResolver and streams the resolved bytes from a seekable disk FileStream —
// never a whole-file byte[] (a ~220 MB Opus / ~970 MB lossless managed allocation per request was the
// read-path OOM defect this closes). enableRangeProcessing:true is load-bearing: the seekable FileStream
// lets ASP.NET honour Range: bytes=X- with a 206 slice (seek + Phase 21 windowing). The resolver reports
// the *actually-served* format via ResolvedAudio, so the content-type matches the bytes (audio/ogg on a
// hit, the source MIME on a fallback) and the eventual client decoder dispatches correctly.
//
// Disposal mirrors the lossless GetTrack path exactly: File() takes ownership of the stream on success
// and disposes it after the response; the inner try disposes ResolvedAudio (and its FileStream) on any
// pre-handoff throw so a handle never leaks.
private async Task<ActionResult> GetTrackOpusAsync(string trackId)
{
try
{
var resolved = await _formatResolver.ResolveAsync(trackId, AudioFormat.Opus);
if (resolved is null)
{
_logger.LogWarning("Track not found for Opus request: {TrackId}", trackId);
return NotFound();
}
string contentType;
long streamLength;
Stream innerStream;
try
{
contentType = resolved.ContentType;
// Length from the seekable FileStream — a metadata read, not a body load.
streamLength = resolved.Stream.Length;
innerStream = resolved.Stream;
_logger.LogInformation(
"Streaming track {TrackId} as {Format} ({Size} bytes, {ContentType})",
trackId, resolved.ResolvedFormat, streamLength, contentType);
}
catch
{
await resolved.DisposeAsync();
throw;
}
return File(innerStream, contentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving track as Opus: {TrackId}", trackId);
return StatusCode(500, "Internal server error");
}
}
// GET api/track/{trackId}/opus/seekdata (unauthenticated)
// Returns the Opus setup-header + granule→byte seek-index sidecar bytes (Phase 18.3). The client
// fetches this once on track load and parses it into OpusSeekData (18.4) before issuing any Opus seek.
// Raw octet-stream — the bytes are the OpusSidecar blob exactly as 18.1 stored them. 404 when no sidecar
// is stored (no Opus artifact yet, or an older derive predating the sidecar); the client then degrades
// to lossless, mirroring the C2 posture of the audio path. Same public auth posture as the audio stream.
// The "opus/seekdata" literal suffix keeps this distinct from the audio and waveform routes.
[HttpGet("{trackId}/opus/seekdata")]
public async Task<ActionResult> GetOpusSeekData(string trackId)
{
var sidecar = await _formatResolver.GetOpusSidecarAsync(trackId);
if (sidecar is null)
{
_logger.LogInformation("No Opus sidecar for track: {TrackId}", trackId);
return NotFound();
}
return File(sidecar, "application/octet-stream");
}
// GET api/track/{trackId}/waveform (unauthenticated)
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
@@ -529,6 +868,31 @@ public class TrackController : ControllerBase
});
}
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
// fetches for whatever track is currently playing/selected — the release is only addressing context.
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
@@ -538,15 +902,18 @@ public class TrackController : ControllerBase
[HttpPost("{trackId}/waveform")]
public async Task<ActionResult> GenerateWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
// Streaming compute (Wave 2): the WAV is read from the vault in bounded chunks, never buffered
// whole. Tri-state: null = no vault audio (404), false = present but uncomputable / write failed
// (500), true = stored.
var stored = await _waveformProfileService.ComputeAndStoreProfileStreamingAsync(
_ => _trackContentService.OpenAudioStreamAsync(trackId), trackId, HttpContext.RequestAborted);
if (stored is null)
{
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
if (!stored)
if (stored is false)
{
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate waveform profile.");
@@ -555,6 +922,72 @@ public class TrackController : ControllerBase
return Ok();
}
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform/high-res")]
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
{
// The high-res bucket count is duration-derived. Read the duration from the vault index metadata
// (no body load); its absence means the track has no vault audio → 404.
var duration = await _trackContentService.GetAudioDurationAsync(trackId);
if (duration is null)
{
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
// Streaming compute (Wave 2): bounded read of the vault WAV. Tri-state mapping as in
// GenerateWaveform — null (entry vanished between the metadata read and the compute) → 404.
var stored = await _waveformProfileService.ComputeAndStoreHighResStreamingAsync(
_ => _trackContentService.OpenAudioStreamAsync(trackId), trackId, duration.Value,
HttpContext.RequestAborted);
if (stored is null)
{
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
if (stored is false)
{
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate high-res waveform datum.");
}
return Ok();
}
// POST api/track/{trackId}/opus ([ApiKeyAuthorize])
// Per-track Opus (re)derive trigger (18.5): schedule a single track's background transcode. Enqueue-only
// and non-blocking — the transcode runs on the shared serial worker; this returns as soon as it is
// scheduled. Re-runnable: overwrites any prior artifact in place. trackId is the EntryKey. 404 when the
// track id is unknown. The "opus" literal suffix keeps this distinct from the audio/waveform routes and
// from the parameterized PUT "{trackId}". Returns 202 Accepted — the work is queued, not done inline.
[ApiKeyAuthorize]
[HttpPost("{trackId}/opus")]
public async Task<ActionResult> GenerateOpus(string trackId, CancellationToken cancellationToken)
{
var result = await _unifiedService.EnqueueOpusAsync(trackId, cancellationToken);
if (result.Success)
{
return Accepted();
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
{
return NotFound();
}
_logger.LogError("GenerateOpus failed for {TrackId}: {Error}", trackId, error);
return StatusCode(500, error);
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
+8 -1
View File
@@ -15,7 +15,13 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
<InternalsVisibleTo Include="DeepDrftTests" />
</ItemGroup>
<ItemGroup>
@@ -26,3 +32,4 @@
</Project>
+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--
###
@@ -16,7 +16,9 @@ public record UpdateTrackMetadataRequest(
string Artist,
string? Album,
string? Genre,
string? Description,
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
ReleaseMedium? Medium = null,
int? TrackNumber = null);
+13
View File
@@ -0,0 +1,13 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
/// </summary>
public class UploadSettings
{
public string? StagingPath { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
/// </summary>
public sealed record UploadStagingDirectory(string Path);
}
+55 -2
View File
@@ -4,12 +4,15 @@ using DeepDrftAPI;
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Models;
using DeepDrftAPI.Services;
using DeepDrftAPI.Services.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using NetBlocks.Utilities.Environment;
using System.Threading.RateLimiting;
// Required credential files — must exist before the app will start.
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
@@ -64,6 +67,29 @@ builder.Services
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
builder.Services.AddScoped<UnifiedTrackService>();
// Background Opus transcode (Phase 18.1, OQ6). One singleton is both the enqueue seam
// (IOpusTranscodeQueue, injected into the scoped UnifiedTrackService) and the hosted drain loop
// (IHostedService). It resolves OpusTranscodeService — a singleton — so no scope is captured.
builder.Services.AddSingleton<OpusTranscodeBackgroundService>();
builder.Services.AddSingleton<IOpusTranscodeQueue>(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<OpusTranscodeBackgroundService>());
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
// release-resolution + counter-bump transaction.
builder.Services
.AddScoped<EventRepository>()
.AddScoped<EventManager>()
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
// 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 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.
@@ -85,10 +111,13 @@ builder.Services.AddAuthBlocks(options =>
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
options.AdminUserSettings = new AdminUserSettings
{
@@ -110,6 +139,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
options.KnownProxies.Clear();
});
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
// budget is generous for a real listening session and only bites on scripted spam.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("events", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
var app = builder.Build();
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
@@ -128,6 +176,11 @@ if (app.Environment.IsDevelopment())
app.UseCors("ContentApiPolicy");
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
// unaffected (no global limiter is set).
app.UseRateLimiter();
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
@@ -0,0 +1,18 @@
namespace DeepDrftAPI.Services.Opus;
/// <summary>
/// The enqueue seam for the background Opus transcode (OQ6 / §3.1a). <see cref="UnifiedTrackService"/>
/// depends only on this thin interface — not on the worker — so adding the background derive to the
/// upload/replace paths costs one small dependency, not the whole transcode graph. Enqueuing is
/// non-blocking and best-effort: a freshly uploaded track is already persisted and playable losslessly
/// before anything is enqueued, and the transcode runs off the request thread.
/// </summary>
public interface IOpusTranscodeQueue
{
/// <summary>
/// Schedules a background Opus derive for the track identified by <paramref name="entryKey"/>. Returns
/// immediately. A dropped or failed enqueue must not affect the caller — the track remains
/// lossless-only and eligible for backfill.
/// </summary>
void Enqueue(string entryKey);
}
@@ -0,0 +1,72 @@
using System.Threading.Channels;
using DeepDrftContent.Processors.Opus;
namespace DeepDrftAPI.Services.Opus;
/// <summary>
/// The background worker behind <see cref="IOpusTranscodeQueue"/> (OQ6 / §3.1a). An unbounded in-process
/// channel buffers EntryKeys enqueued by the upload and replace-audio paths; a single hosted loop drains
/// them one at a time and runs <see cref="OpusTranscodeService.TranscodeAndStoreAsync"/> for each. Serial
/// by design — a transcode is CPU-heavy (§3.1), so running them concurrently would starve request
/// handling; one-at-a-time keeps the derive strictly off the hot path without saturating the host.
///
/// This worker IS the queue (implements <see cref="IOpusTranscodeQueue"/>) so enqueue and drain share one
/// channel with no extra indirection. It is registered as a singleton and surfaced under both the
/// interface and <see cref="IHostedService"/>.
/// </summary>
public sealed class OpusTranscodeBackgroundService : BackgroundService, IOpusTranscodeQueue
{
private readonly Channel<string> _channel =
Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true });
private readonly OpusTranscodeService _transcodeService;
private readonly ILogger<OpusTranscodeBackgroundService> _logger;
public OpusTranscodeBackgroundService(
OpusTranscodeService transcodeService,
ILogger<OpusTranscodeBackgroundService> logger)
{
_transcodeService = transcodeService;
_logger = logger;
}
public void Enqueue(string entryKey)
{
if (string.IsNullOrWhiteSpace(entryKey))
return;
if (!_channel.Writer.TryWrite(entryKey))
{
// Unbounded writer only rejects after Complete(), i.e. during shutdown. The track stays
// lossless-only and is eligible for backfill, so a dropped enqueue is non-fatal — log it.
_logger.LogWarning("Opus transcode: could not enqueue {EntryKey} (queue closed).", entryKey);
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var entryKey in _channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await _transcodeService.TranscodeAndStoreAsync(entryKey, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // host shutting down
}
catch (Exception ex)
{
// TranscodeAndStoreAsync already swallows expected failures; this guards the loop against
// anything unexpected so one bad track never kills the worker.
_logger.LogError(ex, "Opus transcode: unhandled failure draining {EntryKey}; worker continues.", entryKey);
}
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_channel.Writer.TryComplete();
return base.StopAsync(cancellationToken);
}
}
@@ -0,0 +1,185 @@
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
{
/// <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 waveform datum at a constant time
/// resolution (≈333 samples/sec derived from the track's duration; see
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms 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. Under the per-track model (phase-12 §5) this is the
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
/// </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);
}
// Duration from the vault index metadata (no body load); its absence means no vault audio.
var duration = await _trackContentService.GetAudioDurationAsync(entryKey);
if (duration is null)
{
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
// high-res datum every track now carries (phase-12 §5). Streamed from the vault in bounded
// chunks (Wave 2): a ~GB mix is never buffered whole. Tri-state — null = entry vanished after
// the metadata read; false = uncomputable / write failed.
var computed = await _waveformProfileService.ComputeAndStoreHighResStreamingAsync(
_ => _trackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
if (computed is null)
{
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
if (computed is false)
{
_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();
}
}
+341 -15
View File
@@ -1,6 +1,8 @@
using DeepDrftAPI.Services.Opus;
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
@@ -17,10 +19,30 @@ namespace DeepDrftAPI.Services;
public class UnifiedTrackService
{
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: ";
/// <summary>
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
/// detail follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
private readonly WaveformProfileService _waveformProfileService;
private readonly IOpusTranscodeQueue _opusTranscodeQueue;
private readonly TrackFormatResolver _formatResolver;
private readonly ILogger<UnifiedTrackService> _logger;
public UnifiedTrackService(
@@ -28,12 +50,16 @@ public class UnifiedTrackService
ITrackService sqlTrackService,
FileDb fileDatabase,
WaveformProfileService waveformProfileService,
IOpusTranscodeQueue opusTranscodeQueue,
TrackFormatResolver formatResolver,
ILogger<UnifiedTrackService> logger)
{
_contentTrackContentService = contentTrackContentService;
_sqlTrackService = sqlTrackService;
_fileDatabase = fileDatabase;
_waveformProfileService = waveformProfileService;
_opusTranscodeQueue = opusTranscodeQueue;
_formatResolver = formatResolver;
_logger = logger;
}
@@ -49,15 +75,78 @@ public class UnifiedTrackService
string artist,
string? album,
string? genre,
string? description,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber,
long? releaseId,
CancellationToken ct)
{
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
// orphans audio. Two paths:
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
// duplicate and is blocked (409).
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
// to the release row 1 just created. No (title, artist) lookup — the release id is
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
// future bounded medium is covered by the same line).
ResolvedRelease? resolved = null;
if (!string.IsNullOrWhiteSpace(album))
{
if (releaseId is { } attachId)
{
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!attachPeek.Success)
{
var error = attachPeek.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}");
}
// The attach target must be the same release the natural key resolves to — a guard against
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
if (attachPeek.Value is not { } target || target.Id != attachId)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
"Start the upload again.");
}
var cardinalityCheck = CheckCardinality(target);
if (cardinalityCheck is { } violation)
return ResultContainer<TrackDto>.CreateFailResult(violation);
resolved = new ResolvedRelease(target.Id);
}
else
{
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}");
}
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
// edits or appends to an existing release.
if (peek.Value is { } existing)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
// resolved stays null → FindOrCreateRelease below creates the release.
}
}
var unpersisted = await _contentTrackContentService.AddTrackAsync(
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName, cancellationToken: ct);
if (unpersisted is null)
{
@@ -69,23 +158,33 @@ public class UnifiedTrackService
// Resolve the release FK before persisting the track. An upload with an album lands on the
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
// on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? resolvedReleaseId = resolved?.Id;
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
{
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
// mints the release. (The attach path already resolved the id from the pre-check above and
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
var releaseData = new ReleaseDto
{
Title = album,
Artist = artist,
Genre = genre,
Description = description,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
Medium = medium,
CreatedByUserId = createdByUserId,
};
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
// rather than silently attaching, keeping the DB unique index as the final safety net.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null)
if (!releaseResult.Success)
{
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
@@ -94,11 +193,21 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
releaseId = releaseResult.Value.Id;
var (resolvedRelease, wasCreated) = releaseResult.Value;
if (!wasCreated)
{
// The winning concurrent upload created this release between our peek and our insert.
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
resolvedReleaseId = resolvedRelease.Id;
}
var trackDto = TrackConverter.Convert(unpersisted);
trackDto.ReleaseId = releaseId;
trackDto.ReleaseId = resolvedReleaseId;
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
var saveResult = await _sqlTrackService.Create(trackDto);
@@ -113,27 +222,244 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform profile: both stores succeeded, so the upload is a success
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
// Schedule the low-data Opus derive (OQ6 / §3.1a): the track is persisted and lossless-playable
// NOW; the transcode + seek-index build run on a background worker. Non-blocking and best-effort
// — the upload response never waits on it, and a transcode failure leaves the track lossless-only.
_opusTranscodeQueue.Enqueue(unpersisted.EntryKey);
return saveResult;
}
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
// The release a track resolved onto before the vault write. A null Id is the create path (mint
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
private readonly record struct ResolvedRelease(long Id);
// The cardinality guard shared by the attach path and (historically) the create path: a release
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
// message, or null when the add is within limits. The create path never trips this (a brand-new
// release has zero tracks and admits its first), so only the attach path calls it today.
private static string? CheckCardinality(ReleaseDto release)
{
var cardinality = MediumRules.CardinalityOf(release.Medium);
if (release.TrackCount + 1 > cardinality.Max)
{
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
}
return null;
}
/// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
/// new duration to SQL. Track id, EntryKey, release membership, track number, and all other
/// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat
/// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than
/// failing the replace. The duration write is not best-effort — a failure is surfaced so derived
/// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. No release-cardinality cascade
/// applies: the track count is unchanged, so the single-track-Mix case stays intact.
/// </summary>
public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct)
{
var lookup = await _sqlTrackService.GetById(trackId);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error);
return Result.CreateFailResult("Failed to load track.");
}
if (lookup.Value is null)
{
return Result.CreateFailResult(TrackNotFoundMessage);
}
var entryKey = lookup.Value.EntryKey;
var newDuration = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath, ct);
if (newDuration is null)
{
_logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey);
return Result.CreateFailResult("Failed to process and store the replacement audio.");
}
// The old waveform no longer matches the new bytes. Regenerate both datums in place, keyed by
// the same EntryKey (the re-run overwrites the stale data). The store path no longer hands back
// a buffer, so the waveform compute re-reads the freshly stored audio from the vault — the same
// path the upload uses. That re-read is now a bounded streaming pass (Wave 2); neither the store
// nor the compute holds the whole file. Best-effort throughout: a datum failure never fails the replace.
await TryStoreWaveformDatumsAsync(entryKey, ct);
// Write the new duration to SQL. The vault bytes are already swapped, so this is the
// authoritative metadata update for the replace. A failure here is surfaced (unlike the
// best-effort waveform regen above) because a stale DurationSeconds silently corrupts
// derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint).
var durationWrite = await _sqlTrackService.SetDuration(trackId, newDuration.Value, ct);
if (!durationWrite.Success)
{
var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}",
trackId, entryKey, error);
return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
}
// The stale Opus artifact (if any) no longer matches the new source. Schedule a background
// regenerate — the transcode service overwrites the prior artifacts in place keyed by the same
// EntryKey. Best-effort, off the request thread, mirrors the waveform regen above.
_opusTranscodeQueue.Enqueue(entryKey);
return Result.CreatePassResult();
}
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both are reduced in a
// SINGLE streaming pass over the vault audio (Wave 2): the duration comes from the vault index
// metadata (no body load) and the PCM is streamed in bounded chunks through two accumulators, so a
// ~GB mix never lands its whole body in a managed byte[]. Best-effort throughout — never fails upload.
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
{
try
{
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
var duration = await _contentTrackContentService.GetAudioDurationAsync(entryKey);
if (duration is null)
{
_logger.LogWarning(
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
entryKey);
return;
}
await _waveformProfileService.ComputeAndStoreAllStreamingAsync(
_ => _contentTrackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
/// <summary>
/// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
/// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
/// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
/// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
/// failure is logged and skipped, never aborting the batch.
/// </summary>
public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
{
var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
if (!missing.Success || missing.Value is null)
{
var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
}
var updated = 0;
var skipped = 0;
foreach (var track in missing.Value)
{
ct.ThrowIfCancellationRequested();
// Read the duration from the vault index metadata (no audio body load) — the same value the
// processor wrote at upload. Bounds this admin path too (Wave 2): a backfill over a catalogue
// of long mixes no longer pulls each whole file into memory just to read its runtime.
var duration = await _contentTrackContentService.GetAudioDurationAsync(track.EntryKey);
if (duration is null)
{
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
track.EntryKey, track.Id);
skipped++;
continue;
}
var write = await _sqlTrackService.UpdateDuration(track.Id, duration.Value, ct);
if (!write.Success)
{
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
skipped++;
continue;
}
updated++;
}
_logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
}
/// <summary>
/// Backfill-Opus (18.5, OQ4): enqueue a background Opus derive for every non-deleted track that lacks a
/// complete Opus artifact (missing audio OR missing sidecar — a half-derived track is treated as missing
/// and re-derived). Mirrors the duration-backfill posture: enumerate SQL rows, check each against the
/// <c>track-opus</c> vault, schedule the misses. Enqueue-only and non-blocking — the actual transcodes run
/// on the shared background worker, serially (the same queue the upload/replace paths feed), so this
/// returns as soon as the misses are scheduled rather than waiting on CPU-heavy transcodes. Idempotent:
/// a re-run only enqueues tracks still missing Opus, and already-queued/in-flight derives simply overwrite
/// in place. Returns (enqueued, skipped) — skipped = tracks that already have a complete Opus artifact.
/// </summary>
public async Task<ResultContainer<(int Enqueued, int Skipped)>> BackfillOpusAsync(CancellationToken ct)
{
var all = await _sqlTrackService.GetAll();
if (!all.Success || all.Value is null)
{
var error = all.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillOpusAsync: failed to load tracks: {Error}", error);
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
}
var enqueued = 0;
var skipped = 0;
foreach (var track in all.Value)
{
ct.ThrowIfCancellationRequested();
if (await _formatResolver.HasOpusAsync(track.EntryKey))
{
skipped++;
continue;
}
_opusTranscodeQueue.Enqueue(track.EntryKey);
enqueued++;
}
_logger.LogInformation("BackfillOpusAsync complete: {Enqueued} enqueued, {Skipped} already had Opus.",
enqueued, skipped);
return ResultContainer<(int, int)>.CreatePassResult((enqueued, skipped));
}
/// <summary>
/// Per-track Opus (re)derive trigger (18.5): schedule a background transcode for one track. Returns false
/// only when the track id is unknown; the enqueue itself is non-blocking and best-effort, like the bulk
/// backfill. Re-runnable — overwrites any prior artifact in place.
/// </summary>
public async Task<Result> EnqueueOpusAsync(string entryKey, CancellationToken ct)
{
var lookup = await _sqlTrackService.GetByEntryKey(entryKey);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("EnqueueOpusAsync: lookup failed for {EntryKey}: {Error}", entryKey, error);
return Result.CreateFailResult("Failed to load track.");
}
if (lookup.Value is null)
return Result.CreateFailResult(TrackNotFoundMessage);
_opusTranscodeQueue.Enqueue(entryKey);
return Result.CreatePassResult();
}
/// <summary>
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
+70
View File
@@ -4,6 +4,7 @@ using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using DeepDrftContent.Processors.Opus;
using Microsoft.Extensions.Logging;
using NetBlocks.Utilities.Environment;
@@ -43,12 +44,61 @@ namespace DeepDrftAPI
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
InitializeTrackOpusVault(db).GetAwaiter().GetResult();
return db;
});
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
// on-disk copies of an upload off /tmp onto the data disk:
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
// Default location is a "staging" subdirectory beside the vaults; override with
// Upload:StagingPath in appsettings.json.
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
Directory.CreateDirectory(stagingPath);
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
// absent, so set it (and create the dir) before any request is served.
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
// Opus low-data transcode (Phase 18.1). The domain service lives in DeepDrftContent; the host
// owns only the engine config and the background worker. Bitrate/ffmpeg-path come from the
// OpusTranscode config section; StagingPath is forced to the same data-disk staging directory
// the upload path uses so large transcode temp files never land on the /tmp tmpfs.
builder.Services.Configure<OpusTranscodeOptions>(
builder.Configuration.GetSection(nameof(OpusTranscodeOptions)));
builder.Services.PostConfigure<OpusTranscodeOptions>(o => o.StagingPath = stagingPath);
builder.Services.AddSingleton<FfmpegOpusEncoder>();
builder.Services.AddSingleton<OpusTranscodeService>();
// Opus delivery format resolution + sidecar lookup (Phase 18.2). The seam 18.3 calls behind
// the ?format= stream param and the sidecar path. Stateless over the FileDatabase + content
// service singletons; the lossless branch reuses the existing read path unchanged (C2).
builder.Services.AddSingleton<TrackFormatResolver>();
return Task.CompletedTask;
}
/// <summary>
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
/// </summary>
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
{
var path = string.IsNullOrWhiteSpace(configuredPath)
? Path.Combine(vaultPath, "staging")
: configuredPath;
return Path.GetFullPath(path);
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Tracks))
@@ -64,5 +114,25 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
}
}
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
{
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
}
}
// Ensure the track-opus vault exists (Phase 18.1). Holds the derived low-data Ogg Opus artifacts
// — the Opus audio bytes and the setup-header + seek-index sidecar — keyed by the track's EntryKey.
private static async Task InitializeTrackOpusVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.TrackOpus))
{
await fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
}
}
}
}
+6 -4
View File
@@ -1,19 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"DeepDrftContent.Controllers.TrackController": "Information"
"Default": "Information"
}
},
"AllowedHosts": "*",
"Upload": {
"StagingPath": ""
},
"CorsSettings": {
"AllowedOrigins": [
"https://localhost:12778",
"https://localhost:5004",
"http://localhost:5003",
"https://deepdrft.com",
"https://www.deepdrft.com"
"https://www.deepdrft.com",
"https://app.deepdrft.com"
]
},
"ForwardedHeaders": {
@@ -8,7 +8,9 @@
},
"Email": {
"Host": "smtp.your-provider.com",
"Token": "your-email-token-here"
"Token": "your-email-token-here",
"From": "noreply@yourdomain.com",
"TestInbox": "<sandbox-id>"
},
"Admin": {
"UserName": "admin",
+56 -15
View File
@@ -18,7 +18,9 @@ DeepDrftContent.Services/
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
├── Processors/
── AudioProcessor.cs # WAV file parsing, metadata extraction
── AudioProcessor.cs # WAV file parsing, metadata extraction, streaming PCM header parse
│ ├── AudioStoreStream.cs # Bounded streaming copy/prefix helpers used by the processors
│ └── ProcessedAudio.cs # Store-path plan: metadata + streamed WriteToAsync callback
├── Constants/
│ └── VaultConstants.cs # Vault name definitions
├── TrackService.cs # Content-side orchestrator
@@ -47,6 +49,8 @@ FileBinary (base: byte buffer)
Each has a matching `*Dto` variant for base64 JSON transport (e.g., `AudioBinaryDto` with buffer encoded as base64).
**Read/load path**: vault reads (`LoadResourceAsync<AudioBinary>`) still return a full-buffer `AudioBinary`. **Write/store path** is streaming: audio processors return a `ProcessedAudio` plan (metadata + `WriteToAsync` callback); `RegisterResourceStreamingAsync` / `MediaVault.AddEntryStreamingAsync` write bytes to a temp file, then `File.Move` atomic-rename into place. The full `AudioBinary` buffer is never materialized on the store path.
### Index lifecycle
- **DirectoryIndex**: Root index file (at `{rootPath}/index`). Tracks which vaults exist.
@@ -70,18 +74,34 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
try { /* store and update index */ }
catch { return false; } // Swallow, return false
}
// Streaming counterpart (Wave 1 OOM fix) — same bool contract:
public async Task<bool> RegisterResourceStreamingAsync(
string vaultId, string entryId, MetaData metaData,
Func<Stream, CancellationToken, Task> writeContent, CancellationToken ct)
{
try { /* stream to temp → atomic rename → update index */ }
catch { return false; } // Swallow (logs non-cancel exceptions), return false
}
```
`MediaVault.AddEntryStreamingAsync` (called internally) writes bytes to a temp file in the vault directory, then `File.Move(temp, final, overwrite: true)` (atomic POSIX rename on the Linux prod host), then updates the index. A cancel or I/O fault before the rename leaves any prior backing file intact; the temp file is cleaned up best-effort.
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
## Audio processors
Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`:
- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. Returns `AudioBinary` with metadata. On parse failure, logs warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo). Accepts standard PCM (audioFormat=1), WAVE_FORMAT_EXTENSIBLE with PCM SubFormat (0x0001), IEEE Float SubFormat (0x0003), and Padded 24-in-32 containers; normalizes Float and padded inputs to standard 24-bit PCM before storage.
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav``AudioProcessor`, `.mp3``Mp3AudioProcessor`, `.flac``FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. **Returns `ProcessedAudio`** (metadata + streamed `WriteToAsync` callback — no whole-file buffer). Header window grows in 64 KB steps capped at 8 MB; the audio body is never read during processing. Standard PCM WAV is stored verbatim (passthrough via `AudioStoreStream.CopyFileAsync`); EXTENSIBLE-PCM / IEEE-float / padded-container WAVs stream their normalization to standard 24-bit PCM. On parse failure, falls back to defaults (180s / 1411 kbps). Also exposes `TryExtractPcm(ReadOnlySpan<byte>)` for the whole-buffer waveform parity oracle and `TryReadPcmStreamInfoAsync(stream, totalLength)` for the streaming waveform compute path (bounded header parse from a stream; returns `WavPcmStreamInfo?` with `DataStart`/`DataLength`/format fields).
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Reads a bounded ≤8 MB prefix for header parsing; skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. **Returns `ProcessedAudio`** (passthrough plan — MP3 stored unmodified). On parse failure, falls back to defaults (180s / 320 kbps).
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Reads a bounded ≤64 KB prefix. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. **Returns `ProcessedAudio`** (passthrough plan — FLAC stored unmodified). On parse failure, falls back to defaults (180s / 1411 kbps).
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav``AudioProcessor`, `.mp3``Mp3AudioProcessor`, `.flac``FlacAudioProcessor`. **Returns `ProcessedAudio?`**. Throws `ArgumentException` for unsupported extensions.
- `ProcessedAudio`: Store-path plan returned by all processors. Carries `Extension`, `Duration`, `Bitrate`, `Size`, and a `WriteToAsync(destination, ct)` callback that streams the canonical vault bytes to the destination without materializing the whole file. `ProcessedAudio.Passthrough(sourcePath, ...)` builds a passthrough plan via `AudioStoreStream.CopyFileAsync`.
- `AudioStoreStream`: Internal bounded-buffer streaming helpers. `CopyFileAsync(sourcePath, destination, ct)` does a bounded 80 KB disk-to-disk copy; `ReadPrefixAsync(path, cap, ct)` reads at most `cap` bytes from the start of a file (used by processors for header parsing without loading the body).
- `ILoudnessAlgorithm` / `ILoudnessAccumulator`: The loudness strategy interface now exposes both `Compute(ReadOnlySpan<byte>, ...)` (whole-buffer, retained as the parity oracle for tests — no production callers) and `CreateAccumulator(pcmByteLength, ...)``ILoudnessAccumulator` (streaming: `Add(ReadOnlySpan<byte>)` / `Finish()``double[]`). `RmsLoudnessAlgorithm` implements both; `Compute` is defined in terms of the accumulator, so streaming and whole-buffer outputs are byte-identical.
- `WaveformProfileService`: Streaming loudness compute + vault store. The whole-buffer `ComputeAndStoreAsync` / `ComputeAndStoreHighResAsync` methods are **retained but have no production callers** — they exist as the byte-identity parity oracle for tests; do not delete them. The production paths are: `ComputeAndStoreProfileStreamingAsync` (512-bucket seeker profile, tri-state `bool?`), `ComputeAndStoreHighResStreamingAsync` (duration-derived high-res datum, tri-state `bool?`), and `ComputeAndStoreAllStreamingAsync` (both datums in a SINGLE streaming pass, used by the upload/replace hot path, tri-state `bool?`). Tri-state: `null` = no backing audio stream; `false` = audio present but not WAV-decodable or vault write failed; `true` = stored. Streaming reads the WAV in bounded ≤80 KB chunks through one accumulator per datum; peak memory is O(bucket arrays + read buffer), independent of file size.
- `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12.
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
@@ -99,20 +119,41 @@ The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType
## Content-side TrackService (orchestrator)
### AddTrackFromWavAsync(filePath)
### AddTrackAsync(audioFilePath, ...)
1. Reads a WAV file from disk.
2. Calls `AudioProcessor.ProcessWavFileAsync``AudioBinary`.
3. Generates a GUID entry key (via `Guid.NewGuid().ToString()`).
4. Ensures the `tracks` vault exists (creates if missing).
5. Calls `FileDatabase.RegisterResourceAsync("tracks", entryKey, audioBinary)`.
6. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL).
The primary upload entry point. Format-agnostic — routes by extension via `AudioProcessorRouter`.
**Note**: The caller (CLI or web) is responsible for then saving this entity to SQL via `DeepDrftWeb.Services.TrackService.Create`. If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback).
1. Calls `AudioProcessorRouter.ProcessAudioFileAsync(filePath)``ProcessedAudio` plan (no whole-file buffer — Wave 1 OOM fix).
2. Generates a GUID entry key (via `Guid.NewGuid().ToString()`).
3. Ensures the `tracks` vault exists (creates if missing).
4. Builds `MetaData` from the plan's header-extracted fields and calls `FileDatabase.RegisterResourceStreamingAsync("tracks", entryKey, metaData, processed.WriteToAsync)` — bytes are streamed from the staging file to the vault via a bounded copy; `MediaVault.AddEntryStreamingAsync` writes to a temp file then atomic-renames into place.
5. Returns a populated `TrackEntity` (with `Id = 0` since it's not yet in SQL, and `DurationSeconds` populated from the header parse).
If the vault write succeeds and SQL write fails, audio is orphaned (no compensating rollback).
### AddTrackFromWavAsync(filePath, ...)
Backward-compatible shim — delegates to `AddTrackAsync`. The router accepts WAV alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
### ReplaceTrackAudioAsync(entryKey, audioFilePath)
Swaps the vault bytes for an existing track in place, under the same `entryKey`. Captures the old extension from the vault index metadata (not by loading the file) so a cross-format swap can clean up the stale backing file post-success. Streams the new audio via `ProcessedAudio` + `RegisterResourceStreamingAsync` (no whole-file buffer — Wave 1 OOM fix). Returns the new audio's duration on success, `null` on failure (original audio left intact).
### GetAudioBinaryAsync(entryKey)
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns `null` if not found or read fails.
Reads audio from the `tracks` vault via `FileDatabase.LoadResourceAsync<AudioBinary>("tracks", entryKey)`. Returns a full-buffer `AudioBinary` or `null` if not found. This is a full-buffer convenience read — **not** on the audio-delivery hot path. The delivery path now uses `TrackFormatResolver` (Opus: `MediaVault.GetEntryStreamAsync`; lossless: `OpenAudioMediaStreamAsync`). `GetAudioBinaryAsync` is retained as a read-back oracle used by tests (`AudioStoreStreamingTests`, `TrackReplaceAudioTests`, `TrackFormatDeliveryTests`).
### OpenAudioStreamAsync(entryKey)
Returns a read-only, seekable `Stream` over a track's vault audio without buffering the whole file. Used by the streaming waveform compute path (`WaveformProfileService`). Caller owns and must dispose the stream. Returns `null` if the entry has no backing file.
### OpenAudioMediaStreamAsync(entryKey)
Returns a `MediaStream?` — a read-only, non-buffering stream over a track's vault audio together with its stored extension, or `null` if the entry has no backing file. Same non-buffering contract as `OpenAudioStreamAsync`, but exposes `.Extension` alongside `.Stream` so the caller can name a staging file by the original format (the Opus transcode stages the source with the correct extension so ffmpeg can detect the format). The caller owns the returned `MediaStream` and must dispose it. Follows the FileDatabase swallow-and-return-null contract.
### GetAudioDurationAsync(entryKey)
Reads the stored audio duration from the vault index metadata only — no audio body load. Used by the streaming waveform compute to derive the duration-based bucket count, and by `UnifiedTrackService.BackfillDurationsAsync`. Returns `null` if the entry is unknown or carries no audio metadata.
### InitializeTracksVaultAsync()
@@ -120,7 +161,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
`VaultConstants.Tracks = "tracks"`, `VaultConstants.Images = "images"`, `VaultConstants.TrackWaveforms = "track-waveforms"`, and `VaultConstants.TrackOpus = "track-opus"` — the vault names in production use. `TrackWaveforms` holds the per-track high-res waveform datum keyed by `TrackEntity.EntryKey` (Phase 12; renamed from the former `mix-waveforms`, which was Mix-only). `TrackOpus` holds two entries per track: the derived Opus audio (keyed by `entryKey`, extension `.opus`) and the combined setup-header + seek-index sidecar (keyed by `{entryKey}-sidecar`, extension `.opusidx`). New vault names go here when adding new vault types.
## Service registration
@@ -20,4 +20,21 @@ public static class VaultConstants
/// from <c>TrackEntity.ImagePath</c>.
/// </summary>
public const string Images = "images";
/// <summary>
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
/// </summary>
public const string TrackWaveforms = "track-waveforms";
/// <summary>
/// Vault name for the derived low-data Ogg Opus artifacts, keyed by the track's EntryKey (Phase 18,
/// S2). Holds two entries per track: the Opus audio bytes (<c>.opus</c>) and the combined setup-header
/// + granule→byte seek-index sidecar (<c>.opusidx</c>). Both are best-effort derived artifacts —
/// regenerable, and a track without them still plays losslessly. Distinct from the source <c>tracks</c>
/// vault so the source means exactly one thing (mirrors the <c>track-waveforms</c> precedent).
/// </summary>
public const string TrackOpus = "track-opus";
}
@@ -206,6 +206,7 @@ public static class MimeTypeExtensions
{ ".flac", "audio/flac" },
{ ".aac", "audio/aac" },
{ ".ogg", "audio/ogg" },
{ ".opus", "audio/ogg" },
{ ".m4a", "audio/mp4" }
};
@@ -178,6 +178,46 @@ public class FileDatabase : DirectoryIndexDirectory, IDisposable
return false;
}
/// <summary>
/// Registers a resource by streaming its bytes into the vault, without materializing the whole
/// file in a managed <c>byte[]</c> (the store-path OOM fix). The caller supplies the index
/// <paramref name="metaData"/> and a <paramref name="writeContent"/> callback that emits bytes to
/// the backing stream. Swallows exceptions and returns false, matching
/// <see cref="RegisterResourceAsync"/>'s contract — callers check the bool.
/// </summary>
public async Task<bool> RegisterResourceStreamingAsync(
string vaultId,
string entryId,
MetaData metaData,
Func<Stream, CancellationToken, Task> writeContent,
CancellationToken cancellationToken = default)
{
try
{
var directoryVault = _vaults.Get(vaultId);
if (directoryVault != null)
{
var written = await directoryVault.AddEntryStreamingAsync(entryId, metaData, writeContent, cancellationToken);
_logger.LogInformation(
"Streamed {Bytes} bytes into vault {VaultId} entry {EntryId} (no whole-file buffer).",
written, vaultId, entryId);
return true;
}
}
catch (Exception ex)
{
// Swallow and return false, matching RegisterResourceAsync. Log at error for real failures
// only — a normal client cancel (OperationCanceledException) is not an error condition and
// would spam the error log on every client disconnect during a large upload or replace.
if (ex is not OperationCanceledException)
{
_logger.LogError(ex, "RegisterResourceStreamingAsync failed for vault {VaultId} entry {EntryId}", vaultId, entryId);
}
}
return false;
}
/// <summary>
/// Removes a resource from a specific vault. Returns null if the vault does not exist,
/// false if the entry was not found, true if the entry was removed. Distinguishing
@@ -56,6 +56,63 @@ public abstract class MediaVault : VaultIndexDirectory
await FileUtils.PutFileAsync(mediaPath, buffer);
}
/// <summary>
/// Streams an entry's bytes into the vault without ever materializing the whole file in memory.
/// The metadata is supplied by the caller (there is no in-memory <see cref="FileBinary"/> to infer
/// it from) — the store path (upload / replace-audio) sources its bytes from a staging file, not a
/// buffer. Returns the number of bytes written, for the caller to log.
///
/// Write ordering (atomic-replace guarantee): bytes are streamed to a temp file in the same vault
/// directory, the temp file is renamed over the final backing-file path (POSIX <c>rename(2)</c> —
/// atomic on the Linux prod host), and the index is updated only after the rename succeeds.
/// This ordering ensures: (a) the index never advertises a not-yet-present file; (b) a client
/// disconnect or I/O fault during the write leaves any prior backing file intact and the index
/// unchanged; (c) the temp file is cleaned up best-effort on any failure before re-throwing so the
/// vault directory stays tidy. The caller treats a thrown exception as a failed register.
/// </summary>
public async Task<long> AddEntryStreamingAsync(
string entryId,
MetaData metaData,
Func<Stream, CancellationToken, Task> writeContent,
CancellationToken cancellationToken = default)
{
var finalPath = GetMediaPathFromEntryKey(entryId, metaData.Extension);
var tempPath = Path.Combine(RootPath, Path.GetRandomFileName() + ".tmp");
try
{
long bytesWritten;
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
{
await writeContent(tempStream, cancellationToken);
await tempStream.FlushAsync(cancellationToken);
bytesWritten = tempStream.Length;
}
// Rename into place — atomic on the Linux prod host (POSIX rename(2)); overwrites any
// existing same-extension backing file safely on the replace path.
File.Move(tempPath, finalPath, overwrite: true);
// Update the index only after the file is durably in place. A crash between Move and
// AddToIndexAsync leaves an unreferenced file on disk (a harmless orphan recoverable
// by a vault scan); a crash or cancel during the temp write leaves the original backing
// file and the index both unchanged.
await AddToIndexAsync(entryId, metaData);
return bytesWritten;
}
catch
{
// Best-effort temp-file cleanup. After a successful rename tempPath is gone and the
// delete is a no-op. After a write failure or cancel tempPath holds partial bytes that
// must be removed so the vault directory stays tidy.
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best-effort */ }
throw;
}
}
/// <summary>
/// Retrieves an entry from the vault (MediaVaultType inferred from T)
/// </summary>
+321 -63
View File
@@ -7,12 +7,22 @@ namespace DeepDrftContent.Processors;
/// </summary>
public class AudioProcessor
{
// Header parsing never needs the audio body. Read the file in 64 KB steps until the data-chunk
// header is locatable, capping the window so a pathological file with an enormous pre-data header
// cannot drive an unbounded allocation — such a file simply falls through to default metadata and
// passthrough storage, the same outcome as any unparseable WAV.
private const int HeaderWindowStep = 64 * 1024;
private const int HeaderWindowCap = 8 * 1024 * 1024;
/// <summary>
/// Processes a WAV file and creates an AudioBinary object
/// Processes a WAV file into a <see cref="ProcessedAudio"/> store plan: extracts metadata from a
/// bounded header window (never the whole file) and returns a streamed writer for the canonical
/// vault bytes. Standard PCM is stored verbatim (passthrough copy); EXTENSIBLE-PCM / IEEE-float /
/// padded-container WAVs are normalized to a plain 44-byte standard-PCM WAV, written progressively
/// so the vault only ever holds a format the streaming pipeline already handles.
/// </summary>
/// <param name="filePath">Path to the WAV file</param>
/// <returns>AudioBinary object with metadata</returns>
public async Task<AudioBinary?> ProcessWavFileAsync(string filePath)
public async Task<ProcessedAudio?> ProcessWavFileAsync(string filePath, CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
@@ -26,30 +36,197 @@ public class AudioProcessor
try
{
var buffer = await File.ReadAllBytesAsync(filePath);
var wavInfo = ExtractWavMetadata(buffer);
var fileLength = new FileInfo(filePath).Length;
var window = await ReadWavHeaderWindowAsync(filePath, cancellationToken);
var wavInfo = ExtractWavMetadata(window);
// EXTENSIBLE-PCM is byte-compatible with standard PCM but carries a 40+ byte fmt chunk
// the streaming pipeline never expects. Normalize to a plain 44-byte PCM WAV at storage
// time so the vault only ever holds standard PCM and the client decode path stays unchanged.
var storedBuffer = wavInfo.IsExtensible ? NormalizeToStandardPcm(buffer, wavInfo) : buffer;
if (!wavInfo.IsExtensible)
{
// Standard PCM (or the default-fallback path, which reports IsExtensible = false):
// the source bytes are already a format the pipeline handles, so store them verbatim.
return ProcessedAudio.Passthrough(filePath, ".wav", wavInfo.Duration, wavInfo.Bitrate, fileLength);
}
var parameters = new AudioBinaryParams(
Buffer: storedBuffer,
Size: storedBuffer.Length,
Extension: ".wav",
Duration: wavInfo.Duration,
Bitrate: wavInfo.Bitrate
);
// EXTENSIBLE → streamed normalization. The output data size is derivable from the source
// data size alone (no body read needed): verbatim keeps it, float drops 1 byte per sample
// (4→3), padded keeps only the valid bytes per container sample.
var dataStart = (long)wavInfo.DataChunkPos + 8;
var available = fileLength - dataStart;
var srcDataSize = Math.Min((long)wavInfo.DataSize, available);
return new AudioBinary(parameters);
NormalizeMode mode;
int outBitsPerSample;
long outDataSize;
int containerBytes = 0;
int validBytes = 0;
if (wavInfo.IsFloat)
{
mode = NormalizeMode.Float;
outBitsPerSample = 24;
outDataSize = (srcDataSize / 4) * 3;
}
else if (wavInfo.IsPaddedContainer)
{
mode = NormalizeMode.Padded;
outBitsPerSample = wavInfo.BitsPerSample;
containerBytes = wavInfo.ContainerBitsPerSample / 8;
validBytes = wavInfo.BitsPerSample / 8;
outDataSize = (srcDataSize / containerBytes) * validBytes;
}
else
{
mode = NormalizeMode.Verbatim;
outBitsPerSample = wavInfo.BitsPerSample;
outDataSize = srcDataSize;
}
var channels = wavInfo.Channels;
var sampleRate = wavInfo.SampleRate;
return new ProcessedAudio(
".wav", wavInfo.Duration, wavInfo.Bitrate, 44 + outDataSize,
(destination, ct) => WriteNormalizedWavAsync(
filePath, dataStart, srcDataSize, channels, sampleRate, outBitsPerSample,
outDataSize, mode, containerBytes, validBytes, destination, ct));
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new InvalidOperationException($"Failed to process WAV file: {ex.Message}", ex);
}
}
/// <summary>
/// Reads only enough of the file to contain the fmt chunk and the data chunk's 8-byte header, so
/// metadata parsing never loads the (potentially ~GB) audio body. Grows the window in 64 KB steps
/// until the data chunk is locatable or EOF/<see cref="HeaderWindowCap"/> is hit.
/// </summary>
private static async Task<byte[]> ReadWavHeaderWindowAsync(string filePath, CancellationToken ct)
{
await using var fs = new FileStream(
filePath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: HeaderWindowStep, useAsync: true);
using var ms = new MemoryStream();
var buffer = new byte[HeaderWindowStep];
while (ms.Length < HeaderWindowCap)
{
var read = await fs.ReadAsync(buffer, ct);
if (read == 0)
break;
ms.Write(buffer, 0, read);
// FindChunk returns -1 on a partial window (the data chunk isn't reachable yet), so keep
// reading until it is found or the cap/EOF is hit. On normal files the data chunk header
// sits within the first 64 KB, so this loop runs exactly once.
var soFar = ms.ToArray();
if (FindChunk(soFar, "data") >= 0)
return soFar;
}
return ms.ToArray();
}
/// <summary>
/// Writes a normalized standard-PCM WAV to <paramref name="destination"/>: the 44-byte header
/// followed by the data region streamed from the source in bounded, sample-aligned chunks. No
/// whole-file buffer is ever held — peak memory is O(chunk), independent of duration.
/// </summary>
private async Task WriteNormalizedWavAsync(
string sourcePath, long dataStart, long srcDataSize,
int channels, int sampleRate, int outBitsPerSample, long outDataSize,
NormalizeMode mode, int containerBytes, int validBytes,
Stream destination, CancellationToken ct)
{
var header = BuildStandardPcmHeader(channels, sampleRate, outBitsPerSample, outDataSize);
await destination.WriteAsync(header, ct);
await using var src = new FileStream(
sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
src.Seek(dataStart, SeekOrigin.Begin);
switch (mode)
{
case NormalizeMode.Verbatim:
await CopyBoundedAsync(src, destination, srcDataSize, ct);
break;
case NormalizeMode.Float:
// Each 4-byte float sample becomes 3 bytes of 24-bit PCM.
await TransformBoundedAsync(src, destination, srcDataSize, unit: 4,
transform: (buf, len) => ConvertFloatTo24BitPcm(buf, 0, len), ct);
break;
case NormalizeMode.Padded:
await TransformBoundedAsync(src, destination, srcDataSize, unit: containerBytes,
transform: (buf, len) => RepackPaddedContainer(buf, 0, len, containerBytes * 8, validBytes * 8), ct);
break;
}
}
/// <summary>Bounded copy of exactly <paramref name="totalBytes"/> from src to dest.</summary>
private static async Task CopyBoundedAsync(Stream src, Stream dest, long totalBytes, CancellationToken ct)
{
var buffer = new byte[81920];
var remaining = totalBytes;
while (remaining > 0)
{
var want = (int)Math.Min(buffer.Length, remaining);
var read = await src.ReadAsync(buffer.AsMemory(0, want), ct);
if (read == 0)
break;
await dest.WriteAsync(buffer.AsMemory(0, read), ct);
remaining -= read;
}
}
/// <summary>
/// Streams <paramref name="totalBytes"/> of source data through <paramref name="transform"/> in
/// sample-aligned chunks, writing each transformed chunk to <paramref name="dest"/>. The read
/// buffer is a multiple of <paramref name="unit"/>; leftover bytes that do not complete a sample
/// are carried into the next read, and a final partial sample is dropped (matching the
/// whole-buffer transforms' integer-division behavior).
/// </summary>
private static async Task TransformBoundedAsync(
Stream src, Stream dest, long totalBytes, int unit,
Func<byte[], int, byte[]> transform, CancellationToken ct)
{
var bufLen = Math.Max(unit, (81920 / unit) * unit);
var buffer = new byte[bufLen];
var remaining = totalBytes;
var carried = 0;
while (remaining > 0)
{
var want = (int)Math.Min(bufLen - carried, remaining);
if (want == 0)
break;
var read = await src.ReadAsync(buffer.AsMemory(carried, want), ct);
if (read == 0)
break;
remaining -= read;
var filled = carried + read;
var whole = (filled / unit) * unit;
if (whole > 0)
{
var output = transform(buffer, whole);
await dest.WriteAsync(output, ct);
}
carried = filled - whole;
if (carried > 0)
Array.Copy(buffer, whole, buffer, 0, carried);
}
}
private enum NormalizeMode
{
/// <summary>Sample bytes already standard PCM (EXTENSIBLE-PCM, depth == container width).</summary>
Verbatim,
/// <summary>IEEE float samples converted to 24-bit PCM.</summary>
Float,
/// <summary>Padded container (e.g. 24-in-32) re-packed to the valid depth.</summary>
Padded
}
/// <summary>
/// Extracts the raw PCM data region and format parameters from a WAV buffer, reusing the
/// same chunk-walk and validation as metadata extraction. Returns null if the buffer is not
@@ -111,6 +288,105 @@ public class AudioProcessor
return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
}
/// <summary>
/// Reads only the WAV header region from <paramref name="stream"/> (a bounded window, never the
/// audio body) and returns where the PCM data region begins, how long it is, and the format
/// parameters needed to decode it — the streaming counterpart of <see cref="TryExtractPcm"/>. The
/// data length is clamped against <paramref name="totalFileLength"/> (the true backing-file size),
/// so the caller streams exactly the present PCM. Returns null for the same inputs
/// <see cref="TryExtractPcm"/> rejects — non-WAV bytes (mp3/flac), float, and padded-container
/// EXTENSIBLE — so the caller treats null as "no profile computable" and continues gracefully.
///
/// <paramref name="stream"/> must be positioned at the start; on return its position is past the
/// header window (the caller seeks to <c>DataStart</c> before streaming the body). No whole-file
/// buffer is allocated — peak memory is the bounded header window.
/// </summary>
public async Task<WavPcmStreamInfo?> TryReadPcmStreamInfoAsync(
Stream stream, long totalFileLength, CancellationToken cancellationToken = default)
{
var window = await ReadWavHeaderWindowAsync(stream, cancellationToken);
if (window is null)
{
return null;
}
var validation = ValidateWavStructure(window);
if (!validation.IsValid || validation.IsFloat)
{
return null;
}
WavMetadata metadata;
try
{
metadata = ParseWavMetadata(window, validation);
ValidateAudioParameters(metadata);
if (metadata.IsPaddedContainer)
{
return null;
}
}
catch
{
return null;
}
long dataStart = validation.DataChunkPos + 8;
if (dataStart > totalFileLength)
{
return null;
}
var available = totalFileLength - dataStart;
var dataLength = Math.Min((long)metadata.DataSize, available);
if (dataLength <= 0)
{
return null;
}
return new WavPcmStreamInfo(
dataStart, dataLength, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
}
/// <summary>
/// Reads enough of <paramref name="stream"/> to contain the fmt chunk and the data chunk's 8-byte
/// header, growing in 64 KB steps until the data chunk is locatable or EOF / the
/// <see cref="HeaderWindowCap"/> is reached. Bails after the first read when the bytes are not a
/// RIFF/WAVE container, so a non-WAV stream (mp3/flac) costs one read, not the full cap. Returns
/// null only when nothing could be read.
/// </summary>
private static async Task<byte[]?> ReadWavHeaderWindowAsync(Stream stream, CancellationToken ct)
{
using var ms = new MemoryStream();
var buffer = new byte[HeaderWindowStep];
while (ms.Length < HeaderWindowCap)
{
var read = await stream.ReadAsync(buffer, ct);
if (read == 0)
break;
ms.Write(buffer, 0, read);
var soFar = ms.ToArray();
// Early-out for non-WAV input: once at least the 12-byte RIFF/WAVE preamble is in hand,
// a missing signature means this will never be a WAV — stop rather than read to the cap.
if (soFar.Length >= 12 && !HasRiffWaveSignature(soFar))
return soFar;
// FindChunk returns -1 until the data chunk header is fully in the window; on a normal
// file it sits within the first 64 KB so this loop runs exactly once.
if (FindChunk(soFar, "data") >= 0)
return soFar;
}
return ms.Length > 0 ? ms.ToArray() : null;
}
private static bool HasRiffWaveSignature(byte[] buffer) =>
buffer.Length >= 12
&& System.Text.Encoding.ASCII.GetString(buffer, 0, 4) == "RIFF"
&& System.Text.Encoding.ASCII.GetString(buffer, 8, 4) == "WAVE";
/// <summary>
/// Extracts metadata from WAV file buffer with comprehensive validation
/// </summary>
@@ -317,50 +593,17 @@ public class AudioProcessor
}
/// <summary>
/// Rebuilds an EXTENSIBLE WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1)
/// so the vault only ever holds a format the streaming pipeline already handles. Three source
/// shapes are normalized:
/// <list type="bullet">
/// <item>EXTENSIBLE-PCM (depth == container): sample bytes are byte-identical to standard PCM and
/// copied verbatim; only the header is replaced.</item>
/// <item>IEEE float: 32-bit float samples are converted to 24-bit signed integer PCM.</item>
/// <item>Padded container (e.g. 24-in-32): the padding/sign-extension bytes are stripped, keeping
/// the lowest valid bytes per sample.</item>
/// </list>
/// The output header always reports the valid bit depth (<see cref="WavMetadata.BitsPerSample"/>).
/// Builds the canonical 44-byte standard-PCM WAV header (audioFormat = 1) for a normalized stream.
/// The body is written separately so no whole-file buffer is allocated; this only emits the header
/// the streaming pipeline expects, reporting the valid (post-normalization) bit depth.
/// </summary>
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
private static byte[] BuildStandardPcmHeader(int channels, int sampleRate, int outBitsPerSample, long dataSize)
{
// Clamp the declared data size to what is actually present; some encoders overshoot.
var dataStart = metadata.DataChunkPos + 8;
var available = buffer.Length - dataStart;
var srcDataSize = Math.Min(metadata.DataSize, available);
byte[] dataBytes;
int outBitsPerSample;
if (metadata.IsFloat)
{
dataBytes = ConvertFloatTo24BitPcm(buffer, dataStart, srcDataSize);
outBitsPerSample = 24;
}
else if (metadata.IsPaddedContainer)
{
dataBytes = RepackPaddedContainer(buffer, dataStart, srcDataSize, metadata.ContainerBitsPerSample, metadata.BitsPerSample);
outBitsPerSample = metadata.BitsPerSample;
}
else
{
dataBytes = new byte[srcDataSize];
Array.Copy(buffer, dataStart, dataBytes, 0, srcDataSize);
outBitsPerSample = metadata.BitsPerSample;
}
var dataSize = dataBytes.Length;
const int headerSize = 44;
var result = new byte[headerSize + dataSize];
var result = new byte[headerSize];
var blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
var byteRate = (uint)(metadata.SampleRate * blockAlign);
var blockAlign = (ushort)(channels * (outBitsPerSample / 8));
var byteRate = (uint)(sampleRate * blockAlign);
// RIFF header
System.Text.Encoding.ASCII.GetBytes("RIFF").CopyTo(result, 0);
@@ -371,8 +614,8 @@ public class AudioProcessor
System.Text.Encoding.ASCII.GetBytes("fmt ").CopyTo(result, 12);
BitConverter.GetBytes((uint)16).CopyTo(result, 16);
BitConverter.GetBytes((ushort)1).CopyTo(result, 20); // audioFormat = PCM
BitConverter.GetBytes((ushort)metadata.Channels).CopyTo(result, 22);
BitConverter.GetBytes((uint)metadata.SampleRate).CopyTo(result, 24);
BitConverter.GetBytes((ushort)channels).CopyTo(result, 22);
BitConverter.GetBytes((uint)sampleRate).CopyTo(result, 24);
BitConverter.GetBytes(byteRate).CopyTo(result, 28);
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
@@ -381,8 +624,6 @@ public class AudioProcessor
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
Array.Copy(dataBytes, 0, result, headerSize, dataSize);
return result;
}
@@ -459,7 +700,7 @@ public class AudioProcessor
/// <summary>
/// Finds a chunk in the WAV file buffer with proper alignment handling
/// </summary>
private int FindChunk(byte[] buffer, string chunkId)
private static int FindChunk(byte[] buffer, string chunkId)
{
var chunkBytes = System.Text.Encoding.ASCII.GetBytes(chunkId);
int offset = 12; // Start after RIFF header
@@ -556,4 +797,21 @@ public readonly record struct PcmData(
ReadOnlyMemory<byte> Pcm,
int Channels,
int SampleRate,
int BitsPerSample);
/// <summary>
/// Where a WAV's PCM data region lives and how to decode it, without the bytes themselves — the
/// streaming counterpart of <see cref="PcmData"/>. The caller seeks to <see cref="DataStart"/> and
/// streams exactly <see cref="DataLength"/> bytes through a loudness accumulator.
/// </summary>
/// <param name="DataStart">Absolute byte offset of the first PCM sample (past the data chunk header).</param>
/// <param name="DataLength">PCM region length in bytes, clamped to what the backing file actually holds.</param>
/// <param name="Channels">Number of interleaved channels.</param>
/// <param name="SampleRate">Samples per second.</param>
/// <param name="BitsPerSample">Bit depth per sample (8, 16, 24, or 32).</param>
public readonly record struct WavPcmStreamInfo(
long DataStart,
long DataLength,
int Channels,
int SampleRate,
int BitsPerSample);
@@ -24,18 +24,18 @@ public class AudioProcessorRouter
}
/// <summary>
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning an
/// <see cref="AudioBinary"/> carrying the stored bytes and extracted metadata. Throws
/// <see cref="ArgumentException"/> for unsupported extensions.
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning a
/// <see cref="ProcessedAudio"/> store plan (extracted metadata plus a streamed writer for the
/// canonical vault bytes). Throws <see cref="ArgumentException"/> for unsupported extensions.
/// </summary>
public async Task<AudioBinary?> ProcessAudioFileAsync(string filePath)
public async Task<ProcessedAudio?> ProcessAudioFileAsync(string filePath, CancellationToken cancellationToken = default)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath),
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath),
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath),
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath, cancellationToken),
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath, cancellationToken),
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath, cancellationToken),
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
};
}
@@ -0,0 +1,58 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Bounded-buffer streaming primitives shared by the audio processors on the store path. None of
/// these hold the whole file in memory: copies move a fixed window at a time, and the header read
/// caps its allocation regardless of file size.
/// </summary>
internal static class AudioStoreStream
{
private const int CopyBufferSize = 81920; // 80 KB — matches the controller staging copy.
/// <summary>
/// Bounded disk-to-disk copy of <paramref name="sourcePath"/> into <paramref name="destination"/>.
/// Used for passthrough formats whose stored bytes equal the source bytes. Hand-rolled rather than
/// <see cref="Stream.CopyToAsync(Stream)"/> because <c>FileStream</c>'s override writes in 128 KB
/// blocks; this keeps every write at or below <see cref="CopyBufferSize"/>, so peak managed memory
/// is provably O(buffer), never O(filesize).
/// </summary>
public static async Task CopyFileAsync(string sourcePath, Stream destination, CancellationToken ct)
{
await using var src = new FileStream(
sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: CopyBufferSize, useAsync: true);
var buffer = new byte[CopyBufferSize];
int read;
while ((read = await src.ReadAsync(buffer, ct)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), ct);
}
}
/// <summary>
/// Reads at most <paramref name="cap"/> bytes from the start of <paramref name="path"/> — enough
/// for header/metadata parsing without loading the (potentially ~GB) body. Bounds the allocation
/// at <c>min(cap, fileLength)</c>. Size-based metadata (e.g. average bitrate) must use the true
/// file length, supplied separately, not the prefix length.
/// </summary>
public static async Task<byte[]> ReadPrefixAsync(string path, long cap, CancellationToken ct)
{
await using var fs = new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: CopyBufferSize, useAsync: true);
var length = (int)Math.Min(cap, fs.Length);
var buffer = new byte[length];
var total = 0;
while (total < length)
{
var read = await fs.ReadAsync(buffer.AsMemory(total, length - total), ct);
if (read == 0)
break;
total += read;
}
return total == length ? buffer : buffer[..total];
}
}
@@ -12,7 +12,11 @@ public class FlacAudioProcessor
private const double FallbackDuration = 180.0;
private const int FallbackBitrate = 1411;
public async Task<AudioBinary?> ProcessFlacFileAsync(string filePath)
// STREAMINFO is mandatory and always the first metadata block, immediately after the 4-byte magic
// (data at offset 8, 34 bytes). A small prefix read covers it without loading the body.
private const long HeaderCap = 64 * 1024;
public async Task<ProcessedAudio?> ProcessFlacFileAsync(string filePath, CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
@@ -24,25 +28,21 @@ public class FlacAudioProcessor
throw new ArgumentException("File must be a FLAC file", nameof(filePath));
}
var buffer = await File.ReadAllBytesAsync(filePath);
var meta = ExtractFlacMetadata(buffer);
var fileLength = new FileInfo(filePath).Length;
var window = await AudioStoreStream.ReadPrefixAsync(filePath, HeaderCap, cancellationToken);
var meta = ExtractFlacMetadata(window, fileLength);
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Extension: ".flac",
Duration: meta.Duration,
Bitrate: meta.Bitrate);
return new AudioBinary(parameters);
// FLAC is stored unmodified — passthrough the original bytes via a streamed disk-to-disk copy.
return ProcessedAudio.Passthrough(filePath, ".flac", meta.Duration, meta.Bitrate, fileLength);
}
/// <summary>
/// Validates the <c>fLaC</c> magic and the leading STREAMINFO block, then computes duration from
/// total-samples / sample-rate and average bitrate from file size. On any parse failure, logs a
/// warning and returns synthetic defaults — never throws.
/// warning and returns synthetic defaults — never throws. <paramref name="fileLength"/> is the true
/// file size (the header window may be shorter), used for the average-bitrate computation.
/// </summary>
private static FlacMetadata ExtractFlacMetadata(byte[] buffer)
private static FlacMetadata ExtractFlacMetadata(byte[] buffer, long fileLength)
{
try
{
@@ -84,7 +84,7 @@ public class FlacAudioProcessor
var duration = (double)totalSamples / sampleRate;
var bitrate = duration > 0
? (int)(buffer.LongLength * 8L / (duration * 1000))
? (int)(fileLength * 8L / (duration * 1000))
: FallbackBitrate;
return new FlacMetadata { Duration = duration, Bitrate = bitrate };
@@ -20,4 +20,46 @@ public interface ILoudnessAlgorithm
/// is 1. All zeros when the signal is silent (peak is 0) or no samples are present.
/// </returns>
double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount);
/// <summary>
/// Creates a stateful accumulator that reduces the same loudness profile from PCM fed in bounded
/// chunks rather than from one contiguous buffer. The streaming waveform path uses this so a long
/// track's PCM is never materialized whole in a managed <c>byte[]</c>. The accumulator's output is
/// byte-identical to <see cref="Compute"/> for the same total PCM, because <see cref="Compute"/> is
/// itself defined in terms of one — the single source of truth for the loudness reduction.
/// </summary>
/// <param name="pcmByteLength">
/// Total length of the PCM data region in bytes. Required up front because the bucket each frame
/// lands in is derived from the frame's position relative to the total frame count.
/// </param>
/// <param name="channels">Number of interleaved channels; averaged to mono per frame.</param>
/// <param name="sampleRate">Samples per second (used for the envelope-smoothing time base).</param>
/// <param name="bitsPerSample">Bit depth (8 unsigned, 16/24/32 signed) used to decode samples.</param>
/// <param name="bucketCount">Number of equal time slices to reduce the signal to.</param>
ILoudnessAccumulator CreateAccumulator(
long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount);
}
/// <summary>
/// Stateful, single-pass reducer for one loudness profile. Frames are fed via <see cref="Add"/> in
/// arbitrary (non-frame-aligned) chunks — a partial frame straddling a chunk boundary is carried
/// internally — and <see cref="Finish"/> emits the peak-normalized <c>double[bucketCount]</c>. Not
/// thread-safe; feed one stream sequentially. Reusable across the same stream's chunks only, not
/// across streams.
/// </summary>
public interface ILoudnessAccumulator
{
/// <summary>
/// Feeds the next run of PCM bytes (interleaved, little-endian). Need not be frame-aligned; bytes
/// that do not complete a frame are retained until the next call. Bytes past the total frame count
/// declared at construction are ignored, matching the whole-buffer path's trailing-partial-frame drop.
/// </summary>
void Add(ReadOnlySpan<byte> pcmChunk);
/// <summary>
/// Finalizes and returns the peak-normalized loudness profile (<c>double[bucketCount]</c>, each in
/// [0, 1]). All zeros for silence or a degenerate (no-frame) input. Call once, after the last
/// <see cref="Add"/>.
/// </summary>
double[] Finish();
}
+20 -17
View File
@@ -25,7 +25,13 @@ public class Mp3AudioProcessor
private const double FallbackDuration = 180.0;
private const int FallbackBitrate = 320;
public async Task<AudioBinary?> ProcessMp3FileAsync(string filePath)
// Metadata lives in the leading ID3v2 tag plus the first MPEG frame. Cap the header read so a
// large MP3 is not pulled into memory whole just to read it; a tag larger than this (very large
// embedded art) simply falls back to the CBR/default estimate, never an OOM. The body is stored
// by streaming the original file, not from this window.
private const long HeaderCap = 8 * 1024 * 1024;
public async Task<ProcessedAudio?> ProcessMp3FileAsync(string filePath, CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
@@ -37,24 +43,21 @@ public class Mp3AudioProcessor
throw new ArgumentException("File must be an MP3 file", nameof(filePath));
}
var buffer = await File.ReadAllBytesAsync(filePath);
var meta = ExtractMp3Metadata(buffer);
var fileLength = new FileInfo(filePath).Length;
var window = await AudioStoreStream.ReadPrefixAsync(filePath, HeaderCap, cancellationToken);
var meta = ExtractMp3Metadata(window, fileLength);
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Extension: ".mp3",
Duration: meta.Duration,
Bitrate: meta.Bitrate);
return new AudioBinary(parameters);
// MP3 is stored unmodified — passthrough the original bytes via a streamed disk-to-disk copy.
return ProcessedAudio.Passthrough(filePath, ".mp3", meta.Duration, meta.Bitrate, fileLength);
}
/// <summary>
/// Parses the first valid MPEG frame (after any ID3v2 tag) and any Xing/VBRI tag inside it.
/// On any parse failure, logs a warning and returns synthetic defaults — never throws.
/// <paramref name="fileLength"/> is the true file size (the header window may be shorter), used
/// for the CBR duration estimate.
/// </summary>
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer)
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer, long fileLength)
{
try
{
@@ -65,7 +68,7 @@ public class Mp3AudioProcessor
}
var header = DecodeFrameHeader(buffer, frameStart);
var duration = ComputeDuration(buffer, frameStart, header);
var duration = ComputeDuration(buffer, frameStart, header, fileLength);
return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps };
}
@@ -202,7 +205,7 @@ public class Mp3AudioProcessor
/// Computes duration from a Xing/Info or VBRI tag (accurate for VBR) when present; otherwise
/// falls back to the CBR estimate fileSize / (bitrate_kbps * 125). Guards divide-by-zero.
/// </summary>
private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header)
private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header, long fileLength)
{
var xingFrames = ReadXingFrameCount(buffer, frameStart, header);
if (xingFrames > 0 && header.SampleRate > 0)
@@ -216,10 +219,10 @@ public class Mp3AudioProcessor
return (double)vbriFrames * header.SamplesPerFrame / header.SampleRate;
}
// CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125.
// Exclude the ID3v2 tag bytes (everything before frameStart) from the estimate.
// CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125. Uses the true
// file length (not the bounded header window), excluding the ID3v2 tag bytes before frameStart.
var bytesPerSecond = header.BitrateKbps * 125;
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
return bytesPerSecond > 0 ? (double)(fileLength - frameStart) / bytesPerSecond : FallbackDuration;
}
/// <summary>
@@ -0,0 +1,140 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// Encodes a source audio file (any format the source vault holds — WAV/MP3/FLAC) to Ogg Opus fullband
/// 320 kbps by shelling out to FFmpeg (libopus). FFmpeg is chosen over a managed encoder because it
/// muxes a correct Ogg container with accurate granule positions across every input format — the page
/// structure the seek-index walk depends on — which a raw libopus binding does not provide. The external
/// <c>ffmpeg</c> binary is therefore a host runtime prerequisite (flagged in the wave handoff).
/// </summary>
public sealed class FfmpegOpusEncoder
{
private readonly OpusTranscodeOptions _options;
private readonly ILogger<FfmpegOpusEncoder> _logger;
public FfmpegOpusEncoder(IOptions<OpusTranscodeOptions> options, ILogger<FfmpegOpusEncoder> logger)
{
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Transcodes <paramref name="sourcePath"/> to an Ogg Opus file at <paramref name="destinationPath"/>.
/// Returns true on a clean exit with a non-empty output. Returns false (logged) on a non-zero exit,
/// a timeout, a missing ffmpeg binary, or any process failure — a transcode failure must never throw
/// to the caller (C6); the background worker treats false as "leave the track lossless-only".
/// </summary>
public async Task<bool> EncodeAsync(string sourcePath, string destinationPath, CancellationToken ct)
{
var ffmpeg = string.IsNullOrWhiteSpace(_options.FfmpegPath) ? "ffmpeg" : _options.FfmpegPath;
// -vn drops any cover-art video stream; -map a:0 takes the first audio stream; -ar 48000 forces
// fullband (Opus internally resamples to 48 kHz anyway, but stating it keeps granulepos math
// unambiguous); libopus VBR at the target bitrate; -f ogg for an explicit Ogg container; -y
// overwrites the (pre-created, empty) destination temp file.
var args = new[]
{
"-hide_banner", "-nostdin", "-loglevel", "error",
"-i", sourcePath,
"-vn", "-map", "a:0",
"-c:a", "libopus", "-b:a", $"{_options.BitrateKbps}k",
"-ar", "48000",
"-f", "ogg",
"-y", destinationPath,
};
var psi = new ProcessStartInfo(ffmpeg)
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var process = new Process { StartInfo = psi };
try
{
if (!process.Start())
{
_logger.LogError("Opus transcode: ffmpeg failed to start ({Ffmpeg}).", ffmpeg);
return false;
}
}
catch (Exception ex)
{
// Most commonly a missing binary (Win32Exception "file not found"). This is the ops
// prerequisite failing — log loudly so it is unmistakable in the deploy logs.
_logger.LogError(ex,
"Opus transcode: could not launch ffmpeg ({Ffmpeg}). Is the ffmpeg binary installed on the host?",
ffmpeg);
return false;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
// Drain stderr concurrently — ffmpeg can block writing diagnostics if the pipe is not read.
var stderrTask = process.StandardError.ReadToEndAsync(timeout.Token);
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
TryKill(process);
await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
throw; // genuine shutdown cancellation — let it propagate
}
catch (OperationCanceledException)
{
TryKill(process);
await SafeStderr(stderrTask); // observe to avoid unobserved-task warnings
_logger.LogError("Opus transcode: ffmpeg exceeded the {Timeout}s timeout for {Source}.",
_options.TimeoutSeconds, sourcePath);
return false;
}
var stderr = await SafeStderr(stderrTask);
if (process.ExitCode != 0)
{
_logger.LogError("Opus transcode: ffmpeg exited {Code} for {Source}. stderr: {Stderr}",
process.ExitCode, sourcePath, stderr);
return false;
}
if (!File.Exists(destinationPath) || new FileInfo(destinationPath).Length == 0)
{
_logger.LogError("Opus transcode: ffmpeg exited 0 but produced no output for {Source}.", sourcePath);
return false;
}
return true;
}
private void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Opus transcode: failed to kill timed-out ffmpeg process.");
}
}
private static async Task<string> SafeStderr(Task<string> stderrTask)
{
try { return await stderrTask; }
catch { return "<stderr unavailable>"; }
}
}
@@ -0,0 +1,65 @@
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// Wire-format constants for the Ogg-Opus derived artifacts. Centralised so the seek-index codec,
/// the page walker, and the tests agree on one set of magic numbers.
/// </summary>
public static class OggOpusConstants
{
/// <summary>Opus granule positions are always sample counts at 48 kHz, regardless of input rate.</summary>
public const double OpusSampleRate = 48000.0;
/// <summary>One seek-index entry per this many seconds of audio (OQ7 — 0.5 s buckets).</summary>
public const double SeekBucketSeconds = 0.5;
/// <summary>The Ogg page capture pattern "OggS" — every page starts with these four bytes.</summary>
public static ReadOnlySpan<byte> CapturePattern => "OggS"u8;
/// <summary>Magic signature opening an OpusHead identification header packet.</summary>
public static ReadOnlySpan<byte> OpusHeadSignature => "OpusHead"u8;
/// <summary>Magic signature opening an OpusTags comment header packet.</summary>
public static ReadOnlySpan<byte> OpusTagsSignature => "OpusTags"u8;
/// <summary>
/// Fixed size of an Ogg page header before the segment table: capture(4) + version(1) +
/// header-type(1) + granulepos(8) + serial(4) + sequence(4) + checksum(4) + page-segments(1).
/// </summary>
public const int OggPageHeaderSize = 27;
/// <summary>Byte offset of the 64-bit granule position within an Ogg page header.</summary>
public const int GranulePositionOffset = 6;
/// <summary>Byte offset of the page-segment count (the segment-table length) within the header.</summary>
public const int PageSegmentCountOffset = 26;
/// <summary>Sentinel granule position for a page that ends mid-packet (no usable timestamp).</summary>
public const ulong NoGranulePosition = 0xFFFFFFFFFFFFFFFFUL;
/// <summary>
/// Minimum byte length of an <c>OpusHead</c> packet payload to safely read <c>pre_skip</c>.
/// RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) + pre_skip(2) = 12 bytes minimum.
/// </summary>
public const int OpusHeadMinSize = 12;
/// <summary>
/// Byte offset of <c>pre_skip</c> within the full <c>OpusHead</c> packet payload (including the
/// magic). RFC 7845 §5.1: "OpusHead"(8) + version(1) + channels(1) = 10 bytes before pre_skip.
/// </summary>
public const int OpusHeadPreSkipOffset = 10;
/// <summary>
/// Header size of the serialized seek-index blob:
/// totalBytes(8) + duration(8) + count(4) + preSkip(2) + reserved(2) = 24 bytes.
/// </summary>
public const int SeekIndexHeaderSize = 24;
/// <summary>Size of one serialized seek point: granulepos(8) + byteOffset(8).</summary>
public const int SeekPointSize = 16;
/// <summary>Vault-resource extension for the Opus audio bytes.</summary>
public const string OpusExtension = ".opus";
/// <summary>Vault-resource extension for the combined setup-header + seek-index sidecar.</summary>
public const string SidecarExtension = ".opusidx";
}
@@ -0,0 +1,310 @@
using System.Buffers.Binary;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// The result of walking an encoded Ogg Opus stream once: the captured setup header (the leading
/// <c>OpusHead</c> + <c>OpusTags</c> pages, verbatim) and the bucketed granule→byte seek index. This
/// is everything the sidecar artifact carries (§3.4a) — built at transcode time so delivery never
/// re-walks the stream.
/// </summary>
/// <param name="SetupHeaderBytes">The leading setup pages (OpusHead + OpusTags), exactly as they
/// appear at the start of the stream, ready to prepend to any mid-stream page run before decode.</param>
/// <param name="SeekIndex">The accurate, 0.5 s-bucketed granule→byte transfer function.</param>
public sealed record OggOpusWalk(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex);
/// <summary>
/// Pure Ogg-Opus stream walker. Reads the page structure directly (the <c>OggS</c> capture pattern and
/// the 27-byte page header) to (1) capture the setup-header pages and (2) record, for every audio page,
/// its end granule position and exact byte offset — bucketed to 0.5 s with each bucket boundary snapped
/// to the nearest enclosing page start. No external dependency: the encoder (FFmpeg) produces the bytes;
/// this turns them into the seek artifact deterministically, so it is unit-testable without a codec.
/// </summary>
/// <remarks>
/// Two entry points share one <see cref="WalkState"/> page-processing core, so they produce byte-identical
/// output by construction (the project's parity-oracle convention, mirroring
/// <c>RmsLoudnessAlgorithm.Compute</c> over its accumulator):
/// <list type="bullet">
/// <item><see cref="Walk(ReadOnlySpan{byte})"/> — the whole-buffer overload, retained as the byte-identity
/// parity oracle for the streaming variant.</item>
/// <item><see cref="WalkAsync(System.IO.Stream,System.Threading.CancellationToken)"/> — the streaming
/// variant: walks the page structure from a forward stream in a bounded read buffer (one Ogg page at a
/// time), so peak managed memory is O(buffer + seek-index + setup-header), independent of file size.</item>
/// </list>
/// </remarks>
public static class OggOpusParser
{
/// <summary>
/// The largest a single Ogg page can be: header(27) + a full 255-entry segment table + the maximum
/// payload those segments can describe (255 × 255 bytes). The streaming read buffer is floored at this
/// so a complete page always fits, which means a short read on a page can only mean a truncated stream.
/// </summary>
private const int MaxOggPageSize = OggOpusConstants.OggPageHeaderSize + 255 + 255 * 255; // 65307
/// <summary>
/// Walks <paramref name="oggBytes"/> and produces the setup header + seek index, or null if the
/// bytes are not a recognisable Ogg Opus stream (no setup header, no audio pages, or truncated
/// structure). A null is the caller's signal to treat the transcode as failed and leave the track
/// lossless-only (C6) — it does not throw for malformed input.
/// </summary>
public static OggOpusWalk? Walk(ReadOnlySpan<byte> oggBytes)
{
var state = new WalkState();
var offset = 0;
while (offset + OggOpusConstants.OggPageHeaderSize <= oggBytes.Length)
{
var page = oggBytes.Slice(offset);
if (!page[..4].SequenceEqual(OggOpusConstants.CapturePattern))
{
// Not on a page boundary — the encoder writes contiguous pages, so this means the
// stream is malformed or we mis-stepped. Either way it is unrecoverable here.
return null;
}
var segmentCount = page[OggOpusConstants.PageSegmentCountOffset];
var segmentTableEnd = OggOpusConstants.OggPageHeaderSize + segmentCount;
if (segmentTableEnd > page.Length)
return null; // truncated header
var payloadSize = 0;
for (var i = 0; i < segmentCount; i++)
payloadSize += page[OggOpusConstants.OggPageHeaderSize + i];
var pageTotalSize = segmentTableEnd + payloadSize;
if (pageTotalSize > page.Length)
return null; // truncated payload
var payload = page.Slice(segmentTableEnd, payloadSize);
var granule = BinaryPrimitives.ReadUInt64LittleEndian(
page.Slice(OggOpusConstants.GranulePositionOffset, 8));
state.AddPage(granule, payload, page.Slice(0, pageTotalSize), offset);
offset += pageTotalSize;
}
return state.Finish((ulong)oggBytes.Length);
}
/// <summary>
/// Streaming counterpart of <see cref="Walk(ReadOnlySpan{byte})"/>: walks the Ogg page structure from
/// a forward <paramref name="stream"/> in a bounded read buffer (one Ogg page at a time), producing a
/// byte-identical <see cref="OggOpusWalk"/> without ever holding the whole encoded file in memory.
/// Returns null on the same malformed/truncated conditions as the buffer overload — it does not throw
/// for bad input (only <see cref="OperationCanceledException"/> propagates on cancellation).
/// </summary>
public static Task<OggOpusWalk?> WalkAsync(Stream stream, CancellationToken cancellationToken = default)
=> WalkAsync(stream, MaxOggPageSize, cancellationToken);
/// <summary>
/// Buffer-size-parameterised core. <paramref name="bufferSize"/> is floored at
/// <see cref="MaxOggPageSize"/> so any single page always fits in the buffer; the absolute byte
/// cursor (<c>absoluteOffset</c>) advances as the window compacts, so recorded seek offsets stay
/// absolute even though the buffer holds only a small window at any instant.
/// </summary>
internal static async Task<OggOpusWalk?> WalkAsync(Stream stream, int bufferSize, CancellationToken cancellationToken)
{
var state = new WalkState();
var buffer = new byte[Math.Max(bufferSize, MaxOggPageSize)];
var len = 0; // valid bytes held at buffer[0..len]
long absoluteOffset = 0; // absolute stream position of buffer[0]
while (true)
{
// The buffer overload's loop guard requires a full fixed header before parsing a page; once
// fewer than that remain (and the stream is drained) it is the natural end of the stream.
if (len < OggOpusConstants.OggPageHeaderSize)
{
len += await FillAsync(stream, buffer, len, cancellationToken);
if (len < OggOpusConstants.OggPageHeaderSize)
break;
}
if (!buffer.AsSpan(0, 4).SequenceEqual(OggOpusConstants.CapturePattern))
return null;
var segmentCount = buffer[OggOpusConstants.PageSegmentCountOffset];
var segmentTableEnd = OggOpusConstants.OggPageHeaderSize + segmentCount;
if (len < segmentTableEnd)
{
len += await FillAsync(stream, buffer, len, cancellationToken);
if (len < segmentTableEnd)
return null; // truncated header
}
var payloadSize = 0;
for (var i = 0; i < segmentCount; i++)
payloadSize += buffer[OggOpusConstants.OggPageHeaderSize + i];
var pageTotalSize = segmentTableEnd + payloadSize;
if (len < pageTotalSize)
{
len += await FillAsync(stream, buffer, len, cancellationToken);
if (len < pageTotalSize)
return null; // truncated payload (page never fully arrived before EOF)
}
var page = buffer.AsSpan(0, pageTotalSize);
var granule = BinaryPrimitives.ReadUInt64LittleEndian(
page.Slice(OggOpusConstants.GranulePositionOffset, 8));
var payload = page.Slice(segmentTableEnd, payloadSize);
state.AddPage(granule, payload, page, absoluteOffset);
// Compact the consumed page off the front of the window; the absolute cursor advances by the
// exact page size so every offset the state records remains an absolute stream position.
var remaining = len - pageTotalSize;
if (remaining > 0)
Buffer.BlockCopy(buffer, pageTotalSize, buffer, 0, remaining);
len = remaining;
absoluteOffset += pageTotalSize;
}
return state.Finish((ulong)absoluteOffset + (ulong)len);
}
/// <summary>
/// Fills <paramref name="buffer"/> from <paramref name="offset"/> to its end, issuing as many reads as
/// needed until the buffer is full or the stream is exhausted. Returns the number of bytes added.
/// </summary>
private static async Task<int> FillAsync(Stream stream, byte[] buffer, int offset, CancellationToken ct)
{
var added = 0;
while (offset + added < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(offset + added, buffer.Length - offset - added), ct);
if (read == 0)
break;
added += read;
}
return added;
}
private static bool StartsWith(ReadOnlySpan<byte> payload, ReadOnlySpan<byte> signature) =>
payload.Length >= signature.Length && payload[..signature.Length].SequenceEqual(signature);
/// <summary>
/// The single page-processing core both <see cref="Walk(ReadOnlySpan{byte})"/> and
/// <see cref="WalkAsync(Stream,int,CancellationToken)"/> drive, page by page, in stream order. Holding
/// the setup-header + seek-index accumulation here is what makes the two entry points byte-identical
/// by construction: there is exactly one copy of the OpusHead/OpusTags detection, pre-skip correction,
/// t=0 anchoring, and 0.5 s bucketing logic.
/// </summary>
private sealed class WalkState
{
// The real setup (OpusHead + OpusTags pages) is a few KB; this cap bounds the streaming capture so
// a malformed head-without-tags stream cannot grow it unboundedly. A stream that exceeds it has no
// OpusTags within the cap, so no audio points are ever recorded and Finish returns null either way
// — the cap never changes the output of a stream that produces a non-null result.
private const int MaxSetupHeaderBytes = 8 * 1024 * 1024;
private bool _sawOpusHead;
private bool _sawOpusTags;
private ushort _preSkip;
private int _setupHeaderEnd = -1;
private bool _capturingSetup = true;
private readonly List<byte> _setupHeader = new();
private readonly List<OpusSeekPoint> _points = new();
private ulong _lastGranule;
private double _nextBucketTime;
private bool _firstAudioPointTaken;
/// <summary>
/// Processes one fully-framed page. <paramref name="pageBytes"/> is the whole page (header +
/// segment table + payload) for verbatim setup capture; <paramref name="absoluteOffset"/> is the
/// page's absolute start in the stream — the value recorded in the seek index.
/// </summary>
public void AddPage(ulong granule, ReadOnlySpan<byte> payload, ReadOnlySpan<byte> pageBytes, long absoluteOffset)
{
if (_capturingSetup)
{
if (_setupHeader.Count + pageBytes.Length > MaxSetupHeaderBytes)
_capturingSetup = false; // malformed: give up capture (result will be null without tags)
else
_setupHeader.AddRange(pageBytes);
}
// The setup pages carry no audio granule (OpusHead has granulepos 0; OpusTags too). They
// are the leading pages whose payload opens with the Opus magic signatures.
if (!_sawOpusHead && StartsWith(payload, OggOpusConstants.OpusHeadSignature))
{
_sawOpusHead = true;
_setupHeaderEnd = (int)(absoluteOffset + pageBytes.Length);
// RFC 7845 §5.1 — OpusHead layout after the 8-byte "OpusHead" magic:
// [0] version (1 byte), [1] channel count (1 byte),
// [2-3] pre_skip (little-endian uint16) ← at packet bytes 10-11
// pre_skip is the number of decoder samples to discard before presenting audio;
// all granule→time conversions must subtract it (RFC 7845 §4.3).
if (payload.Length >= OggOpusConstants.OpusHeadMinSize)
_preSkip = BinaryPrimitives.ReadUInt16LittleEndian(
payload.Slice(OggOpusConstants.OpusHeadPreSkipOffset, 2));
}
else if (_sawOpusHead && !_sawOpusTags && StartsWith(payload, OggOpusConstants.OpusTagsSignature))
{
_sawOpusTags = true;
_setupHeaderEnd = (int)(absoluteOffset + pageBytes.Length);
// The setup header ends at the OpusTags page; stop capturing so audio pages never grow it.
_capturingSetup = false;
}
else if (_sawOpusHead && _sawOpusTags)
{
// Audio page. Record the first audio page unconditionally (the seek anchor at t=0),
// then one entry per 0.5 s bucket. A page with no end-granule (mid-packet continuation,
// granulepos == -1) is skipped for indexing — its time is unknown — but still advances
// the byte cursor.
if (granule != OggOpusConstants.NoGranulePosition)
{
// RFC 7845 §4.3: presentation time = max(0, granule preSkip) / 48000.
// Use this corrected time for bucketing so that a stream with pre-skip 3840 (~80 ms)
// does not systematically offset every indexed time by that amount.
var correctedTime = Math.Max(0.0,
(granule - (double)_preSkip) / OggOpusConstants.OpusSampleRate);
if (!_firstAudioPointTaken)
{
// Anchor the first seek point at corrected time = 0 by storing the granule as
// preSkip. This guarantees that a binary search for t=0 ("largest entry with
// corrected time ≤ 0") always resolves to the first audio page's byte offset —
// even when the real granule is slightly above preSkip due to encoder lead-in.
_points.Add(new OpusSeekPoint(_preSkip, (ulong)absoluteOffset));
_firstAudioPointTaken = true;
_nextBucketTime = OggOpusConstants.SeekBucketSeconds;
}
else if (correctedTime >= _nextBucketTime)
{
_points.Add(new OpusSeekPoint(granule, (ulong)absoluteOffset));
// Advance past every bucket this page crossed so a long page does not emit a
// backlog of entries; the next bucket is the first boundary strictly after it.
while (_nextBucketTime <= correctedTime)
_nextBucketTime += OggOpusConstants.SeekBucketSeconds;
}
_lastGranule = granule;
}
}
}
/// <summary>
/// Produces the final walk, or null on the same conditions the buffer overload rejected:
/// no OpusHead, no captured setup header, or no audio seek points. <paramref name="totalByteLength"/>
/// is the full stream length, recorded for end-of-stream seek clamping.
/// </summary>
public OggOpusWalk? Finish(ulong totalByteLength)
{
if (!_sawOpusHead || _setupHeaderEnd < 0 || _points.Count == 0)
return null;
var setupHeader = _setupHeader.ToArray();
// RFC 7845 §4.3: total duration is also pre-skip-corrected, matching the time a listener
// experiences (the last audio page's corrected time, clamped to ≥ 0).
var totalDuration = Math.Max(0.0,
(_lastGranule - (double)_preSkip) / OggOpusConstants.OpusSampleRate);
var index = new OggOpusSeekIndex(_points, totalDuration, totalByteLength, _preSkip);
return new OggOpusWalk(setupHeader, index);
}
}
}
@@ -0,0 +1,124 @@
using System.Buffers.Binary;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// A single seek-index entry: an authoritative 48 kHz <see cref="GranulePosition"/> (Opus granule
/// positions are always sample counts at 48 kHz) paired with the exact byte offset of the Ogg page that
/// carries it. Every <see cref="ByteOffset"/> is a real page-start boundary, so a
/// <c>Range: bytes={ByteOffset}-</c> fetch lands the decoder Ogg-sync-aligned.
/// </summary>
/// <remarks>
/// Per RFC 7845 §4.3, the PCM presentation time is <c>(granulepos preSkip) / 48000</c>. The raw
/// <see cref="GranulePosition"/> is stored here as-is; callers should subtract the containing
/// <see cref="OggOpusSeekIndex.PreSkip"/> before converting to a presentation time. Use
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the corrected value.
/// </remarks>
/// <param name="GranulePosition">The page's end granule position (48 kHz sample count).</param>
/// <param name="ByteOffset">The byte offset of the page start in the Opus file.</param>
public readonly record struct OpusSeekPoint(ulong GranulePosition, ulong ByteOffset)
{
/// <summary>
/// Raw granule-position-to-time conversion (granulepos / 48 kHz). Does NOT subtract pre-skip — use
/// <see cref="OggOpusSeekIndex.PresentationTimeSeconds"/> for the RFC 7845-correct presentation time.
/// </summary>
public double RawTimeSeconds => GranulePosition / OggOpusConstants.OpusSampleRate;
}
/// <summary>
/// The accurate, precomputed transfer function from seek-time to true file byte offset for one Ogg
/// Opus stream (§3.4a A). Built once at transcode time by walking the encoded stream; the client reads
/// it back and binary-searches <see cref="Points"/> instead of doing inaccurate VBR byte-rate math.
/// One entry per 0.5 s of audio (<see cref="OggOpusConstants.SeekBucketSeconds"/>), each snapped to the
/// nearest enclosing page start, plus the totals needed to clamp a seek to range.
/// </summary>
/// <param name="Points">Ordered (granulepos, byteOffset) entries, ascending. The first entry always
/// has <see cref="OpusSeekPoint.GranulePosition"/> == <paramref name="PreSkip"/> (corrected time = 0)
/// and points at the first audio page start, ensuring a seek to t=0 always resolves.</param>
/// <param name="TotalDurationSeconds">
/// Pre-skip-corrected total stream duration: <c>max(0, lastGranule preSkip) / 48000</c>.
/// </param>
/// <param name="TotalByteLength">Total Opus file byte length, for clamping a seek past the end.</param>
/// <param name="PreSkip">
/// The <c>pre_skip</c> value from the <c>OpusHead</c> identification header (RFC 7845 §5.1). Opus
/// decoders must discard this many samples from the decoded start before presenting audio. The client
/// (wave 18.4) needs this to trim the first decoded buffer; storing it here avoids a re-parse of the
/// Ogg stream at delivery time.
/// </param>
public sealed record OggOpusSeekIndex(
IReadOnlyList<OpusSeekPoint> Points,
double TotalDurationSeconds,
ulong TotalByteLength,
ushort PreSkip)
{
/// <summary>
/// Returns the RFC 7845-correct presentation time for a seek point: <c>max(0, granule preSkip) / 48000</c>.
/// Use this for all time comparisons; raw <see cref="OpusSeekPoint.RawTimeSeconds"/> omits the pre-skip.
/// </summary>
public double PresentationTimeSeconds(OpusSeekPoint point) =>
Math.Max(0.0, (point.GranulePosition - (double)PreSkip) / OggOpusConstants.OpusSampleRate);
/// <summary>
/// Serializes the index to the compact little-endian binary blob the sidecar stores. Layout:
/// <c>[uint64 totalByteLength][double totalDurationSeconds][uint32 pointCount][uint16 preSkip][uint16 reserved]</c>
/// then <c>pointCount × (uint64 granulepos, uint64 byteOffset)</c>. The four-byte preSkip+reserved
/// region pads the header to 24 bytes, keeping the point table 8-byte-aligned.
/// Fixed-width records keep the client parse to a single typed-array read.
/// </summary>
public byte[] ToBytes()
{
var size = OggOpusConstants.SeekIndexHeaderSize + Points.Count * OggOpusConstants.SeekPointSize;
var bytes = new byte[size];
var span = bytes.AsSpan();
BinaryPrimitives.WriteUInt64LittleEndian(span[..8], TotalByteLength);
BinaryPrimitives.WriteDoubleLittleEndian(span.Slice(8, 8), TotalDurationSeconds);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(16, 4), (uint)Points.Count);
BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(20, 2), PreSkip);
// bytes 22-23: reserved (zero-initialized by array allocation)
var cursor = OggOpusConstants.SeekIndexHeaderSize;
foreach (var point in Points)
{
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor, 8), point.GranulePosition);
BinaryPrimitives.WriteUInt64LittleEndian(span.Slice(cursor + 8, 8), point.ByteOffset);
cursor += OggOpusConstants.SeekPointSize;
}
return bytes;
}
/// <summary>
/// Parses a blob produced by <see cref="ToBytes"/>. Returns null if the blob is too short or its
/// declared point count does not fit — the storage contract is exact, so a malformed blob is a
/// corruption signal, not a recoverable shape. (Provided so tests and any future server-side reader
/// share one codec with the writer.)
/// </summary>
public static OggOpusSeekIndex? FromBytes(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < OggOpusConstants.SeekIndexHeaderSize)
return null;
var totalByteLength = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]);
var totalDuration = BinaryPrimitives.ReadDoubleLittleEndian(bytes.Slice(8, 8));
var count = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(16, 4));
var preSkip = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(20, 2));
// bytes 22-23: reserved — ignored on read for forward-compatibility
var expected = OggOpusConstants.SeekIndexHeaderSize + (long)count * OggOpusConstants.SeekPointSize;
if (bytes.Length < expected)
return null;
var points = new OpusSeekPoint[count];
var cursor = OggOpusConstants.SeekIndexHeaderSize;
for (var i = 0; i < count; i++)
{
var granule = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor, 8));
var offset = BinaryPrimitives.ReadUInt64LittleEndian(bytes.Slice(cursor + 8, 8));
points[i] = new OpusSeekPoint(granule, offset);
cursor += OggOpusConstants.SeekPointSize;
}
return new OggOpusSeekIndex(points, totalDuration, totalByteLength, preSkip);
}
}
@@ -0,0 +1,57 @@
using System.Buffers.Binary;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// The single derived sidecar artifact per track (§3.4a B, recommended design): the Opus setup header
/// (<c>OpusHead</c> + <c>OpusTags</c>) followed by the granule→byte seek index. The client fetches this
/// once on track load and parses it into its <c>OpusSeekData</c>, so it always has both the setup bytes
/// (to prepend to any mid-stream slice) and the accurate seek transfer function before it ever issues a
/// Range fetch — including a window that opens away from byte 0 (UC9).
/// </summary>
/// <param name="SetupHeaderBytes">The verbatim OpusHead + OpusTags pages.</param>
/// <param name="SeekIndex">The bucketed granule→byte seek index.</param>
public sealed record OpusSidecar(byte[] SetupHeaderBytes, OggOpusSeekIndex SeekIndex)
{
/// <summary>
/// Serializes to <c>[uint32 setupHeaderLength][setup-header bytes][seek-index blob]</c>. The
/// length prefix lets the client split the two regions with one read; the seek-index blob carries
/// its own self-describing header (<see cref="OggOpusSeekIndex.ToBytes"/>), so it needs no trailing
/// length.
/// </summary>
public byte[] ToBytes()
{
var indexBytes = SeekIndex.ToBytes();
var bytes = new byte[4 + SetupHeaderBytes.Length + indexBytes.Length];
var span = bytes.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(span[..4], (uint)SetupHeaderBytes.Length);
SetupHeaderBytes.CopyTo(span.Slice(4));
indexBytes.CopyTo(span.Slice(4 + SetupHeaderBytes.Length));
return bytes;
}
/// <summary>
/// Parses a blob produced by <see cref="ToBytes"/>. Returns null on any structural inconsistency
/// (short blob, length prefix that overruns, or an unparseable index) — the format is exact, so a
/// malformed blob is corruption.
/// </summary>
public static OpusSidecar? FromBytes(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 4)
return null;
var setupLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes[..4]);
var indexStart = 4 + (long)setupLength;
if (bytes.Length < indexStart)
return null;
var setupHeader = bytes.Slice(4, (int)setupLength).ToArray();
var index = OggOpusSeekIndex.FromBytes(bytes.Slice((int)indexStart));
if (index is null)
return null;
return new OpusSidecar(setupHeader, index);
}
}
@@ -0,0 +1,33 @@
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// Host-supplied configuration for the Opus transcode. The only operationally significant knob is
/// <see cref="FfmpegPath"/> — the transcode shells out to FFmpeg (libopus), which must be present on the
/// DeepDrftAPI host (see the wave handoff notes). Defaults target Ogg Opus fullband (48 kHz) at 320 kbps,
/// the artifact the spec fixes (§1).
/// </summary>
public sealed class OpusTranscodeOptions
{
/// <summary>
/// Path to the ffmpeg executable. Empty/null resolves to <c>"ffmpeg"</c> (found on PATH). Override
/// with an absolute path when the binary is not on the host PATH.
/// </summary>
public string FfmpegPath { get; set; } = "ffmpeg";
/// <summary>Target Opus bitrate in kbps. 320 kbps fullband is the fixed artifact quality (§1).</summary>
public int BitrateKbps { get; set; } = 320;
/// <summary>
/// Directory for the transient source/output files the transcode stages. Defaults to the system
/// temp path; the host overrides it to the data-disk upload-staging directory so large files never
/// land on the small RAM-backed <c>/tmp</c> tmpfs (same constraint the upload path already honours).
/// </summary>
public string StagingPath { get; set; } = Path.GetTempPath();
/// <summary>
/// Hard ceiling on a single transcode, in seconds. A run that exceeds it is killed and the track
/// stays lossless-only (C6). Generous by default — a 1 GB mix is CPU-expensive (§3.1) — but bounded
/// so a hung ffmpeg never wedges the background worker.
/// </summary>
public int TimeoutSeconds { get; set; } = 3600;
}
@@ -0,0 +1,189 @@
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// Derives and persists a track's low-data Ogg Opus artifacts (Phase 18.1). Mirrors
/// <see cref="WaveformProfileService"/>'s derived-artifact lifecycle: compute from the stored source,
/// store in a dedicated vault keyed by <c>EntryKey</c>, regenerable, failure-tolerant. For one track it
/// produces two entries in the <see cref="VaultConstants.TrackOpus"/> vault — the Opus audio bytes and a
/// combined setup-header + seek-index sidecar (§3.4a). Strictly additive: the source <c>tracks</c> vault
/// is never touched, and a failure here leaves the track lossless-only and eligible for backfill (C2/C6).
/// </summary>
public sealed class OpusTranscodeService
{
private readonly FileDb _fileDatabase;
private readonly TrackContentService _trackContent;
private readonly FfmpegOpusEncoder _encoder;
private readonly OpusTranscodeOptions _options;
private readonly ILogger<OpusTranscodeService> _logger;
public OpusTranscodeService(
FileDb fileDatabase,
TrackContentService trackContent,
FfmpegOpusEncoder encoder,
IOptions<OpusTranscodeOptions> options,
ILogger<OpusTranscodeService> logger)
{
_fileDatabase = fileDatabase;
_trackContent = trackContent;
_encoder = encoder;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Reads the source audio for <paramref name="entryKey"/> from the <c>tracks</c> vault, transcodes it
/// to Ogg Opus 320, walks the encoded stream to build the seek index + capture the setup header, and
/// stores the Opus bytes and the sidecar in the <see cref="VaultConstants.TrackOpus"/> vault under the
/// same key. Re-runnable — a second call overwrites the prior artifacts (backfill / replace-audio).
/// Returns false (logged) on any failure; never throws for expected failure modes (C6). The only
/// propagated exception is <see cref="OperationCanceledException"/> on genuine shutdown.
/// </summary>
public async Task<bool> TranscodeAndStoreAsync(string entryKey, CancellationToken ct)
{
// Read the source extension + duration from the vault index (no body load) and open a streamed
// read over the source bytes — never the whole-buffer AudioBinary. A 92-min mix source is ~970 MB;
// buffering it (and the encoded output below) was the last unconverted store-path OOM violation.
var trackDuration = await _trackContent.GetAudioDurationAsync(entryKey) ?? 0.0;
var sourceMedia = await _trackContent.OpenAudioMediaStreamAsync(entryKey);
if (sourceMedia is null)
{
_logger.LogWarning("Opus transcode: no source audio in vault for {EntryKey}; skipping.", entryKey);
return false;
}
string? sourcePath = null;
string? opusPath = null;
try
{
// Stage the source to disk in bounded chunks so ffmpeg can read it by file path/extension.
// The inner finally disposes the source stream as soon as the copy is done — the read handle
// is not held across the (long) encode — and guarantees disposal even if staging setup throws.
try
{
Directory.CreateDirectory(_options.StagingPath);
sourcePath = Path.Combine(_options.StagingPath, $"opus-src-{Guid.NewGuid():N}{sourceMedia.Extension}");
opusPath = Path.Combine(_options.StagingPath, $"opus-out-{Guid.NewGuid():N}{OggOpusConstants.OpusExtension}");
await using var staging = new FileStream(
sourcePath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await sourceMedia.Stream.CopyToAsync(staging, bufferSize: 81920, ct);
}
finally
{
await sourceMedia.DisposeAsync();
}
if (!await _encoder.EncodeAsync(sourcePath, opusPath, ct))
return false; // encoder already logged the cause
// Walk the encoded output from a streamed read in a bounded buffer (no whole-file load). The
// seek index and setup header are byte-identical to the buffer walk (parity-tested).
OggOpusWalk? walk;
await using (var opusIn = new FileStream(
opusPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true))
{
walk = await OggOpusParser.WalkAsync(opusIn, ct);
}
if (walk is null)
{
_logger.LogError(
"Opus transcode: ffmpeg produced output but the Ogg stream could not be walked for {EntryKey}; " +
"no artifacts stored.", entryKey);
return false;
}
await EnsureVaultAsync();
// Bitrate from the output file length + duration — both available without buffering the bytes.
var opusLength = new FileInfo(opusPath).Length;
var opusBitrate = trackDuration > 0
? (int)(opusLength * 8 / trackDuration / 1000)
: _options.BitrateKbps;
// Store the audio first, then the sidecar. If the sidecar write fails the Opus bytes are
// present but unseekable — treat that as a failed derive (return false) so a backfill re-runs
// it; do not leave a half-derived track that the delivery layer would treat as complete.
var audioMeta = MetaDataFactory.CreateAudioMetaData(
OpusAudioKey(entryKey), OggOpusConstants.OpusExtension, trackDuration, opusBitrate);
var stagedOpusPath = opusPath;
var audioStored = await _fileDatabase.RegisterResourceStreamingAsync(
VaultConstants.TrackOpus, OpusAudioKey(entryKey), audioMeta,
(destination, token) => AudioStoreStream.CopyFileAsync(stagedOpusPath, destination, token), ct);
if (!audioStored)
{
_logger.LogError("Opus transcode: vault write of Opus audio failed for {EntryKey}.", entryKey);
return false;
}
// The sidecar is the setup header (a few KB) plus the seek index (~16 bytes per 0.5 s bucket);
// it is inherently bounded and already in managed memory, so the whole-buffer write is correct.
var sidecar = new OpusSidecar(walk.SetupHeaderBytes, walk.SeekIndex).ToBytes();
var sidecarBinary = new MediaBinary(new MediaBinaryParams(
sidecar, sidecar.Length, OggOpusConstants.SidecarExtension));
var sidecarStored = await _fileDatabase.RegisterResourceAsync(
VaultConstants.TrackOpus, OpusSidecarKey(entryKey), sidecarBinary);
if (!sidecarStored)
{
_logger.LogError("Opus transcode: vault write of sidecar failed for {EntryKey}.", entryKey);
return false;
}
_logger.LogInformation(
"Opus transcode complete for {EntryKey}: {OpusBytes} bytes, {Points} seek points, {Duration:F1}s.",
entryKey, opusLength, walk.SeekIndex.Points.Count, walk.SeekIndex.TotalDurationSeconds);
return true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Opus transcode failed for {EntryKey}; track stays lossless-only.", entryKey);
return false;
}
finally
{
if (sourcePath is not null)
TryDelete(sourcePath);
if (opusPath is not null)
TryDelete(opusPath);
}
}
/// <summary>The vault entry key under which a track's Opus audio bytes are stored.</summary>
public static string OpusAudioKey(string entryKey) => entryKey;
/// <summary>The vault entry key under which a track's setup-header + seek-index sidecar is stored.</summary>
public static string OpusSidecarKey(string entryKey) => $"{entryKey}-sidecar";
private async Task EnsureVaultAsync()
{
// The TrackOpus vault is created at host startup (Startup.cs), so this guard is normally a
// no-op for the upload path. It is retained for the backfill path, which may run via a
// standalone CLI or a host that skips vault pre-creation, where the vault might not exist.
if (!_fileDatabase.HasVault(VaultConstants.TrackOpus))
await _fileDatabase.CreateVaultAsync(VaultConstants.TrackOpus, MediaVaultType.Audio);
}
private void TryDelete(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Opus transcode: failed to delete staging file {Path}.", path);
}
}
}
@@ -0,0 +1,36 @@
using DeepDrftModels.Enums;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// The outcome of resolving a track + requested <see cref="AudioFormat"/> to a concrete artifact
/// (Phase 18.2; read-path streaming). Carries an <em>open, seekable, disk-backed</em> <see cref="Stream"/>
/// over the artifact's bytes — never a buffered <c>byte[]</c>, so a ~220 MB Opus file or ~970 MB lossless
/// source is never materialized in a managed array per request. Also carries the content-type that matches
/// <em>what was actually returned</em>, and the format actually served — which may differ from the requested
/// one when the C2 fallback fires (Opus requested, no Opus artifact → the lossless artifact + its
/// content-type). The delivery layer (18.3) sets the response <c>Content-Type</c> from
/// <see cref="ContentType"/> so the eventual decoder picks the right decoder for the bytes it receives.
/// <para>
/// Ownership: the resolver opens the stream; the caller takes ownership. On the success path the caller hands
/// <see cref="Stream"/> to <c>File(..., enableRangeProcessing: true)</c>, which disposes it after the
/// response. On any pre-handoff throw the caller disposes this instance (which disposes the stream) so the
/// underlying <see cref="FileStream"/> never leaks — mirroring the lossless disk-stream path's catch-path
/// disposal.
/// </para>
/// </summary>
/// <param name="Stream">An open, seekable, disk-backed stream over the resolved artifact. The caller owns it.</param>
/// <param name="ContentType">The MIME type of the bytes in <paramref name="Stream"/> (e.g. <c>audio/ogg</c>
/// for Opus, or the source's real MIME for lossless).</param>
/// <param name="ResolvedFormat">The format actually returned. Equal to the requested format on a direct
/// hit; <see cref="AudioFormat.Lossless"/> when an Opus request fell back.</param>
public sealed record ResolvedAudio(Stream Stream, string ContentType, AudioFormat ResolvedFormat)
: IDisposable, IAsyncDisposable
{
/// <summary>True when an Opus request was served the lossless artifact because no Opus existed (C2).</summary>
public bool DidFallBack(AudioFormat requested) => requested != ResolvedFormat;
public void Dispose() => Stream.Dispose();
public ValueTask DisposeAsync() => Stream.DisposeAsync();
}
@@ -0,0 +1,129 @@
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftContent.Processors.Opus;
/// <summary>
/// The server-side format resolution + sidecar lookup seam (Phase 18.2). Given a track's
/// <c>EntryKey</c> and a requested <see cref="AudioFormat"/>, returns the correct audio artifact and the
/// content-type that matches it; given an <c>EntryKey</c>, returns the Opus seek/setup sidecar bytes.
/// Downstream waves call this — 18.3 wires it behind the <c>?format=</c> stream param and serves the
/// sidecar over HTTP; this wave delivers only the seam, not the HTTP surface.
/// <para>
/// Additive and non-breaking (C2): the lossless branch streams the source exactly as the existing read
/// path does (via <see cref="TrackContentService.OpenAudioMediaStreamAsync"/>, a non-buffering disk
/// stream), and an Opus request for a track with no Opus artifact falls back to lossless rather than
/// failing. Mirrors the <see cref="WaveformProfileService"/> derived-artifact lookup precedent: read from
/// the dedicated vault, swallow misses to null (FileDatabase convention), let the caller decide.
/// </para>
/// <para>
/// Read-path streaming: artifacts are resolved as open, seekable, disk-backed <see cref="ResolvedAudio"/>
/// handles — never whole-file <c>byte[]</c> loads — so the delivery layer streams them straight to the
/// response (Range/206 honoured by the seekable <c>FileStream</c>) without buffering a ~220 MB Opus file
/// or a ~970 MB lossless source per request.
/// </para>
/// </summary>
public sealed class TrackFormatResolver
{
private readonly FileDb _fileDatabase;
private readonly TrackContentService _trackContentService;
private readonly ILogger<TrackFormatResolver> _logger;
public TrackFormatResolver(
FileDb fileDatabase,
TrackContentService trackContentService,
ILogger<TrackFormatResolver> logger)
{
_fileDatabase = fileDatabase;
_trackContentService = trackContentService;
_logger = logger;
}
/// <summary>
/// Resolves <paramref name="entryKey"/> + <paramref name="requestedFormat"/> to the audio artifact to
/// serve plus its content-type. <see cref="AudioFormat.Lossless"/> resolves the source artifact in the
/// <c>tracks</c> vault with its real MIME (WAV/MP3/FLAC). <see cref="AudioFormat.Opus"/> resolves the
/// derived Opus artifact (<c>audio/ogg</c>) when present, and <strong>falls back to lossless</strong>
/// when it is not (C2). Returns null only when even the lossless source is missing — i.e. the track has
/// no audio at all (an unknown key or a genuinely empty vault), the one case the caller treats as 404.
/// </summary>
public async Task<ResolvedAudio?> ResolveAsync(string entryKey, AudioFormat requestedFormat)
{
if (requestedFormat == AudioFormat.Opus)
{
var opusVault = _fileDatabase.GetVault(VaultConstants.TrackOpus);
if (opusVault is not null)
{
// Disk-backed, seekable stream over the Opus artifact — no whole-file buffer. The caller
// owns the stream (hands it to File(...) on success, disposes on a pre-handoff throw).
var opus = await opusVault.GetEntryStreamAsync(OpusTranscodeService.OpusAudioKey(entryKey));
if (opus is not null)
return new ResolvedAudio(
opus.Stream, MimeTypeExtensions.GetMimeType(opus.Extension), AudioFormat.Opus);
}
// C2 fallback: no Opus artifact yet (legacy row, not backfilled, or transcode failed). Degrade
// to lossless rather than 404 — Opus is strictly additive; its absence never means "no audio".
_logger.LogInformation(
"Opus requested for {EntryKey} but no Opus artifact exists; falling back to lossless.", entryKey);
}
return await ResolveLosslessAsync(entryKey);
}
/// <summary>
/// Resolves the lossless source artifact and its real MIME as a non-buffering disk stream — the existing
/// read path. Shared by the explicit-lossless branch and the Opus fallback so both produce identical
/// bytes + content-type. The returned stream is seekable, so the delivery layer's Range→206 still works.
/// </summary>
private async Task<ResolvedAudio?> ResolveLosslessAsync(string entryKey)
{
var source = await _trackContentService.OpenAudioMediaStreamAsync(entryKey);
if (source is null)
return null;
return new ResolvedAudio(
source.Stream, MimeTypeExtensions.GetMimeType(source.Extension), AudioFormat.Lossless);
}
/// <summary>
/// Returns the Opus setup-header + seek-index sidecar bytes for <paramref name="entryKey"/>, or null
/// when no sidecar is stored (no Opus artifact yet, or an older derive predating the sidecar). 18.3
/// serves these on their own path; 18.4 fetches them once on track load and parses them into the
/// client's <c>OpusSeekData</c>. The bytes are the raw <see cref="OpusSidecar"/> blob
/// (<c>[uint32 setupHeaderLength][setup-header][seek-index]</c>) exactly as 18.1 stored them.
/// </summary>
public async Task<byte[]?> GetOpusSidecarAsync(string entryKey)
{
var sidecar = await _fileDatabase.LoadResourceAsync<MediaBinary>(
VaultConstants.TrackOpus, OpusTranscodeService.OpusSidecarKey(entryKey));
return sidecar?.Buffer;
}
/// <summary>
/// Reports whether <paramref name="entryKey"/> already has a complete Opus derive — both the audio bytes
/// AND the seek/setup sidecar present in the <c>track-opus</c> vault. The Backfill-Opus pass (18.5) uses
/// this to enqueue only tracks that are missing or half-derived (audio without sidecar = unseekable, so
/// treated as incomplete and re-derived). Both halves are required because the transcode stores them in
/// sequence and a sidecar-write failure leaves a track the delivery layer must not treat as Opus-ready.
/// </summary>
public async Task<bool> HasOpusAsync(string entryKey)
{
// Index-only existence — never read a file body. The opus-status admin endpoint calls this in a loop
// over the entire catalogue, so a body load here would stream the whole library's audio sequentially.
// HasIndexEntry is a pure in-memory index lookup (no disk read, no allocation per track).
var opusVault = _fileDatabase.GetVault(VaultConstants.TrackOpus);
if (opusVault is null)
return false;
if (!await opusVault.HasIndexEntry(OpusTranscodeService.OpusAudioKey(entryKey)))
return false;
// Both halves required: audio without the seek/setup sidecar is unseekable, so a half-derived track
// counts as not-having-Opus (the same completeness rule the Backfill-Opus pass enqueues against).
return await opusVault.HasIndexEntry(OpusTranscodeService.OpusSidecarKey(entryKey));
}
}
@@ -0,0 +1,68 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// The product of processing an uploaded audio file on the store path: the metadata SQL and the
/// vault index need, plus a streamed writer that emits the canonical vault bytes to a destination
/// stream without ever materializing the whole file in a managed <c>byte[]</c>.
///
/// This replaces the former whole-file <c>AudioBinary</c> as the processor output for upload /
/// replace-audio (Wave 1 OOM fix): passthrough formats (standard-PCM WAV, MP3, FLAC) stream the
/// source file straight to the destination, and EXTENSIBLE WAVs stream their normalization to
/// standard PCM. The vault <em>load</em> path still uses <c>AudioBinary</c> (a full buffer) — that
/// is the Wave 2 read path and is out of scope here.
///
/// <see cref="WriteToAsync"/> is invoked exactly once by the streaming vault register, against the
/// freshly opened backing <see cref="System.IO.FileStream"/>. The writer re-opens the source file
/// itself, so the source (a staging file) must still exist when the register runs — it does, because
/// processing and registration are sequential within the store call, before the staging-file
/// <c>finally</c> cleanup.
/// </summary>
public sealed class ProcessedAudio
{
/// <summary>The stored file extension (e.g. <c>.wav</c>, <c>.mp3</c>, <c>.flac</c>).</summary>
public string Extension { get; }
/// <summary>Audio duration in seconds, extracted from the header.</summary>
public double Duration { get; }
/// <summary>Audio bitrate in kbps, extracted from (or estimated for) the header.</summary>
public int Bitrate { get; }
/// <summary>
/// The canonical stored byte count — computed from the header and file length, never by
/// buffering the body. Used only for diagnostics (confirming the streamed path was taken).
/// </summary>
public long Size { get; }
private readonly Func<Stream, CancellationToken, Task> _writeTo;
public ProcessedAudio(
string extension,
double duration,
int bitrate,
long size,
Func<Stream, CancellationToken, Task> writeTo)
{
Extension = extension;
Duration = duration;
Bitrate = bitrate;
Size = size;
_writeTo = writeTo;
}
/// <summary>
/// Streams the canonical vault bytes to <paramref name="destination"/>. Bounded-buffer — peak
/// managed memory is O(buffer), not O(filesize).
/// </summary>
public Task WriteToAsync(Stream destination, CancellationToken cancellationToken = default)
=> _writeTo(destination, cancellationToken);
/// <summary>
/// Builds a passthrough plan: the stored bytes are byte-identical to the source file (standard
/// PCM WAV, MP3, FLAC — no transcoding). The writer is a bounded disk-to-disk copy.
/// </summary>
public static ProcessedAudio Passthrough(
string sourcePath, string extension, double duration, int bitrate, long sourceLength)
=> new(extension, duration, bitrate, sourceLength,
(destination, ct) => AudioStoreStream.CopyFileAsync(sourcePath, destination, ct));
}
@@ -3,100 +3,85 @@ namespace DeepDrftContent.Processors;
/// <summary>
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
/// equal time slices, takes the RMS of each slice, applies a ~15 ms envelope-follower smoothing
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
/// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{
/// <summary>
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
/// filter so the smoothing introduces no time lag.
/// </summary>
public const double SmoothingTimeConstantSeconds = 0.005;
/// <summary>
/// Whole-buffer reduction. Defined in terms of <see cref="CreateAccumulator"/> so the streaming and
/// whole-buffer paths share one decode + finalize implementation — byte-identical output by
/// construction, not by parallel maintenance.
/// </summary>
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{
var accumulator = CreateAccumulator(pcmData.Length, channels, sampleRate, bitsPerSample, bucketCount);
accumulator.Add(pcmData);
return accumulator.Finish();
}
public ILoudnessAccumulator CreateAccumulator(
long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{
if (bucketCount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive.");
}
var result = new double[bucketCount];
return new RmsLoudnessAccumulator(pcmByteLength, channels, sampleRate, bitsPerSample, bucketCount);
}
if (channels <= 0)
/// <summary>
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
/// with the audio (no rightward time shift). The coefficient <c>a = exp(bucketSeconds / τ)</c>
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
/// each bucket blends <c>(1 a)</c> of itself with <c>a</c> of the running envelope. A near-zero
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
/// </summary>
internal static void SmoothEnvelope(double[] data, double bucketSeconds)
{
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
{
return result;
return;
}
var bytesPerSample = bitsPerSample / 8;
if (bytesPerSample <= 0)
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
// Forward pass.
var env = data[0];
for (var i = 0; i < data.Length; i++)
{
return result;
env = a * env + (1 - a) * data[i];
data[i] = env;
}
var bytesPerFrame = bytesPerSample * channels;
var frameCount = pcmData.Length / bytesPerFrame;
if (frameCount == 0)
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
env = data[^1];
for (var i = data.Length - 1; i >= 0; i--)
{
return result;
env = a * env + (1 - a) * data[i];
data[i] = env;
}
// Sum of squared mono amplitudes and the frame count, per bucket. A frame's bucket is
// determined by its position in the timeline so buckets are equal-duration slices.
var sumSquares = new double[bucketCount];
var counts = new long[bucketCount];
for (var frame = 0; frame < frameCount; frame++)
{
var frameStart = frame * bytesPerFrame;
double channelSum = 0;
for (var ch = 0; ch < channels; ch++)
{
var sampleStart = frameStart + ch * bytesPerSample;
channelSum += ReadSampleNormalized(pcmData, sampleStart, bitsPerSample);
}
var mono = channelSum / channels;
// long math avoids overflow on large files before the divide back into bucket index.
var bucket = (int)((long)frame * bucketCount / frameCount);
if (bucket >= bucketCount)
{
bucket = bucketCount - 1;
}
sumSquares[bucket] += mono * mono;
counts[bucket]++;
}
var peak = 0.0;
for (var i = 0; i < bucketCount; i++)
{
if (counts[i] > 0)
{
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
if (result[i] > peak)
{
peak = result[i];
}
}
}
if (peak <= 0)
{
// Silence — return all zeros (Array is already zero-initialized).
Array.Clear(result);
return result;
}
for (var i = 0; i < bucketCount; i++)
{
result[i] /= peak;
}
return result;
}
/// <summary>
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
/// </summary>
private static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
internal static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
{
switch (bitsPerSample)
{
@@ -136,3 +121,167 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
}
}
}
/// <summary>
/// Single-pass RMS accumulator backing <see cref="RmsLoudnessAlgorithm"/>. Frames are fed via
/// <see cref="Add"/> in arbitrary chunks; a partial frame straddling a chunk boundary is carried in a
/// one-frame buffer. The per-frame decode, bucket assignment, and per-bucket accumulation are the exact
/// arithmetic the former whole-buffer loop used, in the same frame order, so the floating-point result
/// is bit-identical whether the PCM arrives in one span or many. <see cref="Finish"/> applies the same
/// envelope smoothing and peak-normalization as before. Memory is O(bucketCount + one frame).
/// </summary>
public sealed class RmsLoudnessAccumulator : ILoudnessAccumulator
{
private readonly int _channels;
private readonly int _sampleRate;
private readonly int _bitsPerSample;
private readonly int _bucketCount;
private readonly int _bytesPerSample;
private readonly int _bytesPerFrame;
private readonly long _frameCount;
private readonly double[] _sumSquares;
private readonly long[] _counts;
private readonly byte[] _carry;
private int _carryLen;
private long _frameIndex;
internal RmsLoudnessAccumulator(long pcmByteLength, int channels, int sampleRate, int bitsPerSample, int bucketCount)
{
_channels = channels;
_sampleRate = sampleRate;
_bitsPerSample = bitsPerSample;
_bucketCount = bucketCount;
_sumSquares = new double[bucketCount];
_counts = new long[bucketCount];
// Guards mirror the former whole-buffer Compute exactly: any degenerate parameter leaves
// _frameCount at 0, so Add is a no-op and Finish returns the zero-initialized profile.
_bytesPerSample = bitsPerSample / 8;
if (channels <= 0 || _bytesPerSample <= 0)
{
_bytesPerFrame = 0;
_frameCount = 0;
_carry = [];
return;
}
_bytesPerFrame = _bytesPerSample * channels;
_frameCount = pcmByteLength / _bytesPerFrame;
_carry = new byte[_bytesPerFrame];
}
public void Add(ReadOnlySpan<byte> pcmChunk)
{
if (_frameIndex >= _frameCount)
{
return; // degenerate input, or every expected frame already consumed
}
var pos = 0;
// Complete a frame carried from the previous chunk first.
if (_carryLen > 0)
{
var need = _bytesPerFrame - _carryLen;
var take = Math.Min(need, pcmChunk.Length);
pcmChunk.Slice(0, take).CopyTo(_carry.AsSpan(_carryLen));
_carryLen += take;
pos += take;
if (_carryLen < _bytesPerFrame)
{
return; // still not a full frame
}
ProcessFrame(_carry);
_carryLen = 0;
if (_frameIndex >= _frameCount)
{
return;
}
}
// Whole frames directly from the chunk.
while (pos + _bytesPerFrame <= pcmChunk.Length && _frameIndex < _frameCount)
{
ProcessFrame(pcmChunk.Slice(pos, _bytesPerFrame));
pos += _bytesPerFrame;
}
// Stash a trailing partial frame for the next chunk — but only while frames are still expected.
// A trailing partial frame on the final chunk is dropped, matching the whole-buffer path.
if (_frameIndex < _frameCount && pos < pcmChunk.Length)
{
var remainder = pcmChunk.Slice(pos);
remainder.CopyTo(_carry);
_carryLen = remainder.Length;
}
}
private void ProcessFrame(ReadOnlySpan<byte> frame)
{
double channelSum = 0;
for (var ch = 0; ch < _channels; ch++)
{
channelSum += RmsLoudnessAlgorithm.ReadSampleNormalized(frame, ch * _bytesPerSample, _bitsPerSample);
}
var mono = channelSum / _channels;
// long math avoids overflow on large files before the divide back into bucket index.
var bucket = (int)(_frameIndex * _bucketCount / _frameCount);
if (bucket >= _bucketCount)
{
bucket = _bucketCount - 1;
}
_sumSquares[bucket] += mono * mono;
_counts[bucket]++;
_frameIndex++;
}
public double[] Finish()
{
var result = new double[_bucketCount];
if (_frameCount == 0)
{
return result; // degenerate input — all zeros, as the whole-buffer guards returned
}
for (var i = 0; i < _bucketCount; i++)
{
if (_counts[i] > 0)
{
result[i] = Math.Sqrt(_sumSquares[i] / _counts[i]);
}
}
// Envelope smoothing (~15 ms) then peak-normalization — identical to the whole-buffer finalize.
var totalSeconds = (double)_frameCount / _sampleRate;
var bucketSeconds = totalSeconds / _bucketCount;
RmsLoudnessAlgorithm.SmoothEnvelope(result, bucketSeconds);
var peak = 0.0;
for (var i = 0; i < _bucketCount; i++)
{
if (result[i] > peak)
{
peak = result[i];
}
}
if (peak <= 0)
{
Array.Clear(result);
return result;
}
for (var i = 0; i < _bucketCount; i++)
{
result[i] /= peak;
}
return result;
}
}
@@ -17,6 +17,10 @@ public class WaveformProfileService
{
private const string ProfileExtension = ".wfp";
/// <summary>Bounded read-buffer size for the streaming PCM pass — the only filesize-independent
/// allocation on the streaming path (matches the store path's 80 KB copy buffer).</summary>
private const int StreamReadBufferSize = 81920;
private readonly FileDb _fileDatabase;
private readonly AudioProcessor _audioProcessor;
private readonly ILoudnessAlgorithm _loudnessAlgorithm;
@@ -39,12 +43,25 @@ public class WaveformProfileService
/// <summary>
/// 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
/// 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.
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
/// does not itself decide the count. 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>
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
{
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
@@ -62,15 +79,14 @@ public class WaveformProfileService
value.Channels,
value.SampleRate,
value.BitsPerSample,
_options.BucketCount);
effectiveBucketCount);
var quantized = Quantize(profile);
await EnsureVaultAsync();
await EnsureVaultAsync(effectiveVaultName);
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync(
VaultConstants.WaveformProfiles, entryKey, binary);
var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, entryKey, binary);
if (!stored)
{
@@ -88,14 +104,188 @@ public class WaveformProfileService
}
/// <summary>
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored
/// (existing tracks predate profiling, and computation may have failed). Each byte is a
/// peak-normalized loudness value in [0, 255].
/// Computes a track's high-resolution loudness datum and stores it in the
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
/// datum captures at a constant time resolution regardless of track length. This is the single home
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
/// Returns false (logged) on any failure, per the content-agnostic contract above.
/// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey)
public Task<bool> ComputeAndStoreHighResAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
double durationSeconds)
{
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
}
/// <summary>
/// Streaming counterpart of <see cref="ComputeAndStoreAsync"/>: computes and stores the fixed
/// 512-bucket player-bar profile by reading the WAV from <paramref name="openWavStream"/> in bounded
/// chunks, never materializing the whole file in a managed <c>byte[]</c>. Tri-state result matches
/// the <c>RemoveResourceAsync</c> idiom so callers can map outcomes precisely: <c>null</c> = no audio
/// stream available (the entry has no backing audio); <c>false</c> = audio present but no profile
/// computable (non-WAV / float / padded) or the vault write failed; <c>true</c> = stored. Output is
/// byte-identical to the whole-buffer path for the same WAV.
/// </summary>
public Task<bool?> ComputeAndStoreProfileStreamingAsync(
Func<CancellationToken, Task<Stream?>> openWavStream,
string entryKey,
CancellationToken ct = default) =>
RunStreamingAsync(
openWavStream, entryKey,
[(_options.BucketCount, VaultConstants.WaveformProfiles)], ct);
/// <summary>
/// Streaming counterpart of <see cref="ComputeAndStoreHighResAsync"/>: computes and stores the
/// duration-derived high-res datum (<see cref="VaultConstants.TrackWaveforms"/>) by streaming the WAV
/// from <paramref name="openWavStream"/>. <paramref name="durationSeconds"/> drives the bucket count
/// exactly as the whole-buffer path's <c>audio.Duration</c> did — pass the same vault-metadata
/// duration to keep the stored bytes identical. Tri-state result as in
/// <see cref="ComputeAndStoreProfileStreamingAsync"/>.
/// </summary>
public Task<bool?> ComputeAndStoreHighResStreamingAsync(
Func<CancellationToken, Task<Stream?>> openWavStream,
string entryKey,
double durationSeconds,
CancellationToken ct = default) =>
RunStreamingAsync(
openWavStream, entryKey,
[(WaveformResolution.BucketCountForDuration(durationSeconds), VaultConstants.TrackWaveforms)], ct);
/// <summary>
/// Computes and stores BOTH datums a track carries — the 512-bucket profile and the duration-derived
/// high-res datum — from a SINGLE streaming pass over the WAV. One sequential read of the (possibly
/// ~GB) audio feeds two independent accumulators, so memory stays O(bucket arrays + read buffer) and
/// disk I/O is halved versus two separate passes. This is the upload / replace-audio hot path. Each
/// datum's stored bytes are byte-identical to its whole-buffer counterpart. Tri-state: <c>null</c> =
/// no audio stream; <c>false</c> = not WAV-decodable or a vault write failed; <c>true</c> = both
/// datums stored. Best-effort callers ignore the result.
/// </summary>
public Task<bool?> ComputeAndStoreAllStreamingAsync(
Func<CancellationToken, Task<Stream?>> openWavStream,
string entryKey,
double durationSeconds,
CancellationToken ct = default) =>
RunStreamingAsync(
openWavStream, entryKey,
[
(_options.BucketCount, VaultConstants.WaveformProfiles),
(WaveformResolution.BucketCountForDuration(durationSeconds), VaultConstants.TrackWaveforms),
],
ct);
/// <summary>
/// Core streaming reduction: opens the WAV once, parses its header (bounded), then streams the PCM
/// data region through one loudness accumulator per requested target, storing each datum. All
/// targets are computed in the single pass. See the tri-state contract on the public wrappers.
/// </summary>
private async Task<bool?> RunStreamingAsync(
Func<CancellationToken, Task<Stream?>> openWavStream,
string entryKey,
IReadOnlyList<(int BucketCount, string VaultName)> targets,
CancellationToken ct)
{
try
{
await using var stream = await openWavStream(ct);
if (stream is null)
{
// No backing audio for this entry — distinct from "present but undecodable".
return null;
}
var info = await _audioProcessor.TryReadPcmStreamInfoAsync(stream, stream.Length, ct);
if (info is null)
{
_logger.LogWarning(
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted (streaming).",
entryKey);
return false;
}
var v = info.Value;
var accumulators = new ILoudnessAccumulator[targets.Count];
for (var i = 0; i < targets.Count; i++)
{
accumulators[i] = _loudnessAlgorithm.CreateAccumulator(
v.DataLength, v.Channels, v.SampleRate, v.BitsPerSample, targets[i].BucketCount);
}
await StreamPcmThroughAsync(stream, v.DataStart, v.DataLength, accumulators, ct);
_logger.LogInformation(
"Streaming waveform compute for {EntryKey}: {DataLength} PCM bytes, {TargetCount} datum(s), " +
"{BufferSize}B read buffer — no whole-file load.",
entryKey, v.DataLength, targets.Count, StreamReadBufferSize);
var allStored = true;
for (var i = 0; i < targets.Count; i++)
{
var profile = accumulators[i].Finish();
var quantized = Quantize(profile);
await EnsureVaultAsync(targets[i].VaultName);
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync(targets[i].VaultName, entryKey, binary);
if (!stored)
{
_logger.LogWarning(
"Waveform vault write failed for {EntryKey} in {VaultName}.", entryKey, targets[i].VaultName);
allStored = false;
}
}
return allStored;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Streaming waveform computation failed for {EntryKey}.", entryKey);
return false;
}
}
/// <summary>
/// Seeks to the PCM data region and streams exactly <paramref name="dataLength"/> bytes through each
/// accumulator in bounded reads. The accumulators carry partial frames internally, so the read
/// boundaries need not align to frames. Peak memory is one read buffer — independent of file size.
/// </summary>
private static async Task StreamPcmThroughAsync(
Stream stream, long dataStart, long dataLength, ILoudnessAccumulator[] accumulators, CancellationToken ct)
{
stream.Seek(dataStart, SeekOrigin.Begin);
var buffer = new byte[StreamReadBufferSize];
var remaining = dataLength;
while (remaining > 0)
{
var want = (int)Math.Min(buffer.Length, remaining);
var read = await stream.ReadAsync(buffer.AsMemory(0, want), ct);
if (read == 0)
break;
var span = buffer.AsSpan(0, read);
foreach (var accumulator in accumulators)
{
accumulator.Add(span);
}
remaining -= read;
}
}
/// <summary>
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
/// is stored (existing tracks predate profiling, and computation may have failed). Each byte is
/// a peak-normalized loudness value in [0, 255].
/// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey, string? vaultName = null)
{
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
VaultConstants.WaveformProfiles, entryKey);
vaultName ?? VaultConstants.WaveformProfiles, entryKey);
return binary?.Buffer;
}
@@ -113,11 +303,11 @@ public class WaveformProfileService
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,53 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
///
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
/// length — the direct expression of "high enough resolution regardless of content length."
///
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
/// </summary>
public static class WaveformResolution
{
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
public const int SamplesPerSecond = 333;
/// <summary>
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
/// </summary>
public const int MaxBucketCount = 2_000_000;
/// <summary>
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
/// </summary>
public const int MinBucketCount = 2048;
/// <summary>
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
return MinBucketCount;
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
if (raw >= MaxBucketCount)
return MaxBucketCount;
var buckets = (int)raw;
return buckets < MinBucketCount ? MinBucketCount : buckets;
}
}
+159 -9
View File
@@ -40,13 +40,15 @@ public class TrackContentService
string? album = null,
string? genre = null,
DateOnly? releaseDate = null,
string? originalFileName = null)
string? originalFileName = null,
CancellationToken cancellationToken = default)
{
try
{
// Process the audio file (routed by extension)
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
// Process the audio file (routed by extension). The returned plan carries metadata plus a
// streamed writer — no whole-file buffer (the store-path OOM fix).
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
if (processed == null)
{
throw new InvalidOperationException("Failed to process audio file");
}
@@ -60,8 +62,11 @@ public class TrackContentService
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Store the audio in FileDatabase
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary);
// Stream the audio into the vault. The metadata is supplied directly (there is no in-memory
// AudioBinary on this path), and the bytes are written progressively from the staging file.
var metaData = MetaDataFactory.CreateAudioMetaData(trackId, processed.Extension, processed.Duration, processed.Bitrate);
var success = await _fileDatabase.RegisterResourceStreamingAsync(
VaultConstants.Tracks, trackId, metaData, processed.WriteToAsync, cancellationToken);
if (!success)
{
throw new InvalidOperationException("Failed to store audio in FileDatabase");
@@ -74,7 +79,10 @@ public class TrackContentService
{
EntryKey = trackId, // FileDatabase entry ID
TrackName = trackName,
OriginalFileName = originalFileName
OriginalFileName = originalFileName,
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
// need not touch the vault. Same value the high-res waveform compute reads downstream.
DurationSeconds = processed.Duration
};
return trackEntity;
@@ -97,8 +105,91 @@ public class TrackContentService
string? album = null,
string? genre = null,
DateOnly? releaseDate = null,
string? originalFileName = null) =>
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
string? originalFileName = null,
CancellationToken cancellationToken = default) =>
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName, cancellationToken);
/// <summary>
/// Swaps the audio bytes for an existing track in place: processes a new audio file and
/// re-registers it under the SAME <paramref name="entryKey"/> in the tracks vault. The track's
/// vault key — and therefore its SQL link, release membership, position, and metadata — is
/// untouched; only the binary changes. The new audio is streamed to the vault first; only on
/// confirmed success is a stale old backing file cleaned up. A cross-format replacement (e.g.
/// .wav → .flac) leaves the old file on disk under its former filename once the index is updated;
/// the post-success cleanup removes it. For a same-extension overwrite the register alone suffices.
/// If the register fails the original audio is left intact and null is returned, so the track
/// remains playable. Returns the freshly stored audio's <b>duration</b> on success (the caller
/// re-reads the vault for waveform regen and uses this for the SQL duration write) — matching the
/// FileDatabase swallow-and-return-null contract. The new bytes are never materialized in memory.
/// </summary>
public async Task<double?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath, CancellationToken cancellationToken = default)
{
try
{
// Capture the old extension from the index metadata (not by loading the file — that would
// pull the whole old audio into memory). After register the index points to the new
// extension, so we need the old value now to detect a cross-format swap and clean up the
// stale file post-success.
var trackVault = _fileDatabase.GetVault(VaultConstants.Tracks);
var existingMeta = trackVault is null ? null : await trackVault.GetEntryMetadata(entryKey);
var oldExtension = existingMeta?.Extension;
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
if (processed == null)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
return null;
}
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Stream the new audio in. This upserts the index entry (new extension recorded) and writes
// the new file to disk. If this fails the original entry and file are untouched.
var metaData = MetaDataFactory.CreateAudioMetaData(entryKey, processed.Extension, processed.Duration, processed.Bitrate);
var success = await _fileDatabase.RegisterResourceStreamingAsync(
VaultConstants.Tracks, entryKey, metaData, processed.WriteToAsync, cancellationToken);
if (!success)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved");
return null;
}
// Post-success stale-file cleanup for cross-format swaps. The register wrote the new
// file (e.g. .flac) and updated the index to the new extension, but the old backing
// file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the
// old path — RemoveResourceAsync would now resolve to the new extension and delete the
// wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a
// playback issue (the index no longer references it).
if (oldExtension != null && oldExtension != processed.Extension)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault != null)
{
var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}");
try
{
if (File.Exists(staleFilePath))
File.Delete(staleFilePath);
}
catch (Exception ex)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk");
}
}
}
return processed.Duration;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Retrieves audio binary from FileDatabase
@@ -110,6 +201,65 @@ public class TrackContentService
return await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
}
/// <summary>
/// Opens a read-only, seekable stream over a track's vault audio, or null if the entry has no
/// backing file. The caller owns the stream and must dispose it. Unlike <see cref="GetAudioBinaryAsync"/>
/// this never buffers the whole file — it is the source for the streaming waveform compute. Follows
/// the FileDatabase swallow-and-return-null contract.
/// </summary>
/// <param name="trackId">Track ID (EntryKey)</param>
public async Task<Stream?> OpenAudioStreamAsync(string trackId)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault is null)
{
return null;
}
var media = await vault.GetEntryStreamAsync(trackId);
return media?.Stream;
}
/// <summary>
/// Opens a read-only stream over a track's vault audio together with its stored extension, or null if
/// the entry has no backing file. Same non-buffering contract as <see cref="OpenAudioStreamAsync"/>,
/// but keeps the <see cref="MediaStream.Extension"/> the caller needs to name a staging file for a
/// format-detecting consumer (the Opus transcode reopens the source by extension for ffmpeg). The
/// caller owns the returned <see cref="MediaStream"/> and must dispose it. Follows the FileDatabase
/// swallow-and-return-null contract.
/// </summary>
/// <param name="trackId">Track ID (EntryKey)</param>
public async Task<MediaStream?> OpenAudioMediaStreamAsync(string trackId)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault is null)
{
return null;
}
return await vault.GetEntryStreamAsync(trackId);
}
/// <summary>
/// Reads a track's stored audio duration from the vault index metadata WITHOUT loading the audio
/// body — the cheap counterpart of <c>GetAudioBinaryAsync(...).Duration</c>. Returns null if the
/// entry is unknown or carries no audio metadata. The streaming high-res waveform path uses this to
/// derive the duration-based bucket count, matching the value the whole-buffer path read off
/// <see cref="AudioBinary.Duration"/> so the stored datum is byte-identical.
/// </summary>
/// <param name="trackId">Track ID (EntryKey)</param>
public async Task<double?> GetAudioDurationAsync(string trackId)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault is null)
{
return null;
}
var metaData = await vault.GetEntryMetadata(trackId);
return metaData is AudioMetaData audio ? audio.Duration : null;
}
/// <summary>
/// Checks if FileDatabase is available and tracks vault exists
/// </summary>
+22 -7
View File
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
DeepDrftData/
├── Data/
│ ├── DeepDrftContext.cs # EF DbContext
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
│ └── Configurations/
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
@@ -32,7 +32,7 @@ DeepDrftData/
## EF DbContext and configuration
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
`TrackConfiguration` uses EF fluent API:
- Table name: `track` (singular)
@@ -42,6 +42,7 @@ DeepDrftData/
- `Album`, `Genre`: optional, max 200 / 100
- `ReleaseDate`: optional `DateOnly`
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future)
- `DurationSeconds`: optional `double?` (nullable; populated at upload from vault audio; backfillable via `POST api/track/duration/backfill`; used for aggregate mix-runtime queries). Column: `duration_seconds`. Migration: `20260618155002_AddTrackDuration`.
## Service → Repository → DbContext shape
@@ -49,6 +50,20 @@ DeepDrftData/
- **Repository** (`TrackRepository`): Internal data access. Queries the DbContext. Throws on error (service catches).
- **DbContext** (`DeepDrftContext`): EF Core. Directly accessed by repository, never by service (pattern isolation).
Notable repository / service methods beyond the standard CRUD:
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity``EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: `CountDistinctListenersAsync()` (site-wide), `CountDistinctListenersForTrackAsync(trackEntryKey)`, `CountDistinctListenersForReleaseAsync(releaseId)` — each excludes null `anon_id` rows.
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)` return NetBlocks `Result`. Wave 16.3 added three distinct-count members returning `ResultContainer<int>`: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated). The `anon_id` columns and covering indexes on `play_event`/`share_event` are part of this migration — no additional migration was needed for 16.3.
Example:
```csharp
@@ -117,10 +132,10 @@ Run from the solution root:
```bash
# Add a migration
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
# Apply to database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
```
The design-time factory means you can also run `dotnet ef ... --project DeepDrftData` standalone for local development (it doesn't need the startup project).
@@ -132,9 +147,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
## Connection string
- **DeepDrftAPI**: `environment/connections.json``ConnectionStrings:DefaultConnection`
- Points at the same database (PostgreSQL in production, SQLite for local development).
- Always PostgreSQL (Npgsql) — both production and local development.
The design-time factory hard-codes the local path for `dotnet ef` commands.
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
## Service registration
@@ -142,7 +157,7 @@ In `DeepDrftAPI/Program.cs`:
```csharp
services.AddDbContext<DeepDrftContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>();
services.AddScoped<TrackManager>();
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
@@ -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");
}
}
@@ -0,0 +1,47 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the <c>play_counter</c> rollup (Phase 16 §4.1 / D6). One row per track, unique
/// on track_id so the incremental-on-write bump is an upsert against a single row. <c>TotalPlays</c> is
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
/// </summary>
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
{
public void Configure(EntityTypeBuilder<PlayCounter> builder)
{
builder.ToTable("play_counter");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TrackId)
.IsRequired()
.HasColumnName("track_id");
builder.Property(e => e.PartialCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("partial_count");
builder.Property(e => e.SampledCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
builder.Property(e => e.CompleteCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("complete_count");
// Derived headline figure — never a column.
builder.Ignore(e => e.TotalPlays);
builder.HasIndex(e => e.TrackId)
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
}
}
@@ -0,0 +1,49 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
/// <c>BaseEntity</c> — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
/// query) so the aggregation paths stay cheap as the log grows.
/// </summary>
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
{
public void Configure(EntityTypeBuilder<PlayEvent> builder)
{
builder.ToTable("play_event");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TrackEntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("track_entry_key");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.Bucket)
.IsRequired()
.HasConversion<string>() // Store the readable bucket name, mirroring ReleaseMedium.
.HasMaxLength(20)
.HasColumnName("bucket");
// Reserved nullable token (wave 16.3). Same width as a stringified GUID.
builder.Property(e => e.AnonId)
.HasMaxLength(64)
.HasColumnName("anon_id");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
}
}
@@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
// resolves to one row by its public key.
builder.Property(e => e.EntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("entry_key");
builder.HasIndex(e => e.EntryKey)
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
builder.Property(e => e.Title)
.IsRequired()
.HasMaxLength(200)
@@ -35,6 +47,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(100)
.HasColumnName("genre");
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
builder.Property(e => e.Description)
.HasMaxLength(4000)
.HasColumnName("description");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
@@ -42,6 +59,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasMaxLength(500)
.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)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
@@ -49,6 +78,13 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
.HasColumnName("release_type")
.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)
.HasColumnName("created_by_user_id");
@@ -59,9 +95,12 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
// resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row.
// resulting UniqueViolation and re-queries for the winning row.
// Partial filter excludes soft-deleted rows so re-uploading a deleted release does not
// hit a uniqueness conflict when FindOrCreateRelease creates a fresh row.
builder.HasIndex(e => new { e.Title, e.Artist })
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
}
}
@@ -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");
}
}
@@ -0,0 +1,48 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
/// entity. Indexed on the target key so per-target share tallies stay cheap.
/// </summary>
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
{
public void Configure(EntityTypeBuilder<ShareEvent> builder)
{
builder.ToTable("share_event");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TargetType)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("target_type");
builder.Property(e => e.TargetKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("target_key");
builder.Property(e => e.Channel)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("channel");
builder.Property(e => e.AnonId)
.HasMaxLength(64)
.HasColumnName("anon_id");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
}
}
@@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
.HasColumnName("track_number")
.HasDefaultValue(1);
// Nullable: existing rows carry NULL until the one-time duration backfill populates them.
builder.Property(e => e.DurationSeconds)
.HasColumnName("duration_seconds");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
+13
View File
@@ -12,6 +12,14 @@ public class DeepDrftContext : DbContext
public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; }
public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { get; set; }
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
// FileDatabase vault is not involved.
public DbSet<PlayEvent> PlayEvents { get; set; }
public DbSet<ShareEvent> ShareEvents { get; set; }
public DbSet<PlayCounter> PlayCounters { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -19,5 +27,10 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
}
}
+3 -3
View File
@@ -18,9 +18,9 @@
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
</ItemGroup>
<ItemGroup>
+114
View File
@@ -0,0 +1,114 @@
using DeepDrftData.Repositories;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// <see cref="IEventService"/> implementation over <see cref="EventRepository"/>. The layer boundary
/// matches the rest of DeepDrftData: the repository owns the EF constructs and the write transaction;
/// this service catches at the boundary and returns a NetBlocks <see cref="Result"/>. Telemetry is
/// best-effort by design (§2.2) — a failed write is logged and surfaced as a fail result, never thrown
/// at the caller, so a telemetry hiccup can never reach a listener.
/// </summary>
public class EventManager : IEventService
{
private readonly EventRepository _repository;
private readonly ILogger<EventManager> _logger;
public EventManager(EventRepository repository, ILogger<EventManager> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Result> RecordPlay(
string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default)
{
try
{
await _repository.RecordPlayAsync(trackEntryKey, bucket, anonId, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
_logger.LogError(e, "Failed to record play event for track {TrackEntryKey}", trackEntryKey);
return Result.CreateFailResult(e.Message);
}
}
public async Task<Result> RecordShare(
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null,
CancellationToken cancellationToken = default)
{
try
{
await _repository.RecordShareAsync(targetType, targetKey, channel, anonId, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
_logger.LogError(e, "Failed to record share event for {TargetType} {TargetKey}", targetType, targetKey);
return Result.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountTotalPlaysAsync(cancellationToken);
return ResultContainer<long>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count total plays");
return ResultContainer<long>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersAsync(cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners");
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCountForTrack(
string trackEntryKey, CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey);
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCountForRelease(
long releaseId, CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId);
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
using DeepDrftModels.Enums;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side anonymous telemetry service (Phase 16). Records play and share events to the append-only
/// logs and maintains the incremental play-counter rollup. The release dimension on a play is resolved
/// server-side from the track key (§2.3 / D4) — callers pass only what the client cheaply knows.
/// Returns NetBlocks <see cref="Result"/> at the boundary; the controller maps that to 202/4xx/5xx.
/// </summary>
public interface IEventService
{
/// <summary>
/// Record one play: append a <c>play_event</c> row (release resolved from the track key) and bump
/// the track's <c>play_counter</c> in the same transaction. A play of an unknown/removed track key
/// still logs (with a null release and no counter bump) rather than failing.
/// </summary>
Task<Result> RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Site-wide total play count (Phase 16 §5 — all-time): the sum of every <c>play_counter</c> row's
/// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary
/// figure; the controller composes it onto <c>HomeStatsDto</c> alongside the track-domain figures.
/// </summary>
Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default);
/// <summary>
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c>
/// values across all play events. Null tokens are excluded (not a known listener). The capability for
/// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3.
/// </summary>
Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default);
/// <summary>Distinct listeners who played the given track (by vault entry key). Null tokens excluded.</summary>
Task<ResultContainer<int>> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default);
/// <summary>
/// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in
/// the release counts once. Null tokens excluded.
/// </summary>
Task<ResultContainer<int>> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default);
}
+34
View File
@@ -0,0 +1,34 @@
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>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, 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);
}
+41 -1
View File
@@ -28,14 +28,54 @@ public interface ITrackService
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
/// <summary>
/// Aggregate figures behind the public home hero stat row: Cut track count + per-ReleaseType Cut
/// release breakdown, Mix release count, and total Mix runtime in seconds. One read for all three cards.
/// </summary>
Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default);
/// <summary>
/// Non-deleted tracks whose SQL duration is still null — the work list for the one-time duration
/// backfill. The backfill reads each track's stored duration from the vault and writes it via
/// <see cref="UpdateDuration"/>.
/// </summary>
Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default);
/// <summary>
/// Set the SQL duration for one track. Idempotent: a track whose duration is already set is left
/// untouched. Backs the duration backfill. Returns the number of rows updated (0 or 1).
/// </summary>
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Unconditionally overwrite the SQL duration for one track. Unlike <see cref="UpdateDuration"/>,
/// this carries no null guard — it is for the replace-audio path where the track already has a
/// non-null duration that must be overwritten with the new audio's value. Returns a fail Result
/// when zero rows are affected (track removed between lookup and write).
/// </summary>
Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
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>> Update(TrackDto track);
Task<Result> Delete(long id);
@@ -0,0 +1,179 @@
// <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("20260612102604_MakeReleaseTitleArtistUniquePartial")]
partial class MakeReleaseTitleArtistUniquePartial
{
/// <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.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<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.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.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("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class MakeReleaseTitleArtistUniquePartial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_title_artist",
table: "release");
migrationBuilder.CreateIndex(
name: "IX_release_title_artist",
table: "release",
columns: new[] { "title", "artist" },
unique: true,
filter: "\"is_deleted\" = false");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_title_artist",
table: "release");
migrationBuilder.CreateIndex(
name: "IX_release_title_artist",
table: "release",
columns: new[] { "title", "artist" },
unique: true);
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,308 @@
// <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("20260616035252_AddReleaseDescription")]
partial class AddReleaseDescription
{
/// <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>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "release",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "release");
}
}
}
@@ -0,0 +1,318 @@
// <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("20260616210143_AddReleaseEntryKey")]
partial class AddReleaseEntryKey
{
/// <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>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
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("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
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,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseEntryKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
// mark non-null. No DB default is set on the final column: new rows are app-populated by
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
migrationBuilder.AddColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: true);
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
migrationBuilder.Sql(
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
// 3) Now every row is populated and unique — enforce NOT NULL.
migrationBuilder.AlterColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_release_entry_key",
table: "release",
column: "entry_key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_entry_key",
table: "release");
migrationBuilder.DropColumn(
name: "entry_key",
table: "release");
}
}
}
@@ -0,0 +1,322 @@
// <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("20260618155002_AddTrackDuration")]
partial class AddTrackDuration
{
/// <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>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
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("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
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<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddTrackDuration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "duration_seconds",
table: "track",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "duration_seconds",
table: "track");
}
}
}
@@ -0,0 +1,457 @@
// <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("20260619155610_AddPlayShareTelemetry")]
partial class AddPlayShareTelemetry
{
/// <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.PlayCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CompleteCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("complete_count");
b.Property<long>("PartialCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("partial_count");
b.Property<long>("SampledCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
b.Property<long>("TrackId")
.HasColumnType("bigint")
.HasColumnName("track_id");
b.HasKey("Id");
b.HasIndex("TrackId")
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
b.ToTable("play_counter", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("bucket");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackEntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("track_entry_key");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_play_event_anon_id");
b.HasIndex("ReleaseId")
.HasDatabaseName("IX_play_event_release_id");
b.HasIndex("TrackEntryKey")
.HasDatabaseName("IX_play_event_track_entry_key");
b.ToTable("play_event", (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>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
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("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
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.ShareEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("TargetKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("target_key");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("target_type");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_share_event_anon_id");
b.HasIndex("TargetKey")
.HasDatabaseName("IX_share_event_target_key");
b.ToTable("share_event", (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<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
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,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddPlayShareTelemetry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "play_counter",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
track_id = table.Column<long>(type: "bigint", nullable: false),
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
complete_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L)
},
constraints: table =>
{
table.PrimaryKey("PK_play_counter", x => x.id);
});
migrationBuilder.CreateTable(
name: "play_event",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
release_id = table.Column<long>(type: "bigint", nullable: true),
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_play_event", x => x.id);
});
migrationBuilder.CreateTable(
name: "share_event",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_share_event", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_play_counter_track_id",
table: "play_counter",
column: "track_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_play_event_anon_id",
table: "play_event",
column: "anon_id");
migrationBuilder.CreateIndex(
name: "IX_play_event_release_id",
table: "play_event",
column: "release_id");
migrationBuilder.CreateIndex(
name: "IX_play_event_track_entry_key",
table: "play_event",
column: "track_entry_key");
migrationBuilder.CreateIndex(
name: "IX_share_event_anon_id",
table: "share_event",
column: "anon_id");
migrationBuilder.CreateIndex(
name: "IX_share_event_target_key",
table: "share_event",
column: "target_key");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "play_counter");
migrationBuilder.DropTable(
name: "play_event");
migrationBuilder.DropTable(
name: "share_event");
}
}
}
@@ -22,6 +22,139 @@ namespace DeepDrftData.Migrations
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.PlayCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CompleteCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("complete_count");
b.Property<long>("PartialCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("partial_count");
b.Property<long>("SampledCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
b.Property<long>("TrackId")
.HasColumnType("bigint")
.HasColumnName("track_id");
b.HasKey("Id");
b.HasIndex("TrackId")
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
b.ToTable("play_counter", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("bucket");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackEntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("track_entry_key");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_play_event_anon_id");
b.HasIndex("ReleaseId")
.HasDatabaseName("IX_play_event_release_id");
b.HasIndex("TrackEntryKey")
.HasDatabaseName("IX_play_event_track_entry_key");
b.ToTable("play_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
@@ -45,6 +178,17 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
@@ -61,6 +205,14 @@ namespace DeepDrftData.Migrations
.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");
@@ -85,16 +237,113 @@ namespace DeepDrftData.Migrations
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
.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.ShareEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("TargetKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("target_key");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("target_type");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_share_event_anon_id");
b.HasIndex("TargetKey")
.HasDatabaseName("IX_share_event_target_key");
b.ToTable("share_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
@@ -108,6 +357,10 @@ namespace DeepDrftData.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
@@ -155,6 +408,28 @@ namespace DeepDrftData.Migrations
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")
@@ -167,6 +442,10 @@ namespace DeepDrftData.Migrations
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
+163
View File
@@ -0,0 +1,163 @@
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<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
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,175 @@
using DeepDrftData.Data;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
namespace DeepDrftData.Repositories;
/// <summary>
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
/// track→release at write time and stamps the release id on the row.
///
/// <para>
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
/// BlazorBlocks <c>Repository&lt;&gt;</c> base. It holds the same scoped <see cref="DeepDrftContext"/>
/// the rest of the SQL layer uses, never a service locator.
/// </para>
/// </summary>
public class EventRepository
{
private readonly DeepDrftContext _context;
public EventRepository(DeepDrftContext context)
{
_context = context;
}
/// <summary>
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
/// for a loose track); an unknown key records the event with a null release and no counter bump
/// (there is no track to roll up against). Returns true when the event was written.
/// </summary>
public async Task<bool> RecordPlayAsync(
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
{
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
// a since-removed track still logs (with no counter bump) rather than throwing.
var track = await _context.Tracks
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
.Select(t => new { t.Id, t.ReleaseId })
.FirstOrDefaultAsync(ct);
// The append and the counter bump must commit together — wrap them in one transaction so a
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
// already opened one.
var ownsTransaction = _context.Database.CurrentTransaction is null;
var transaction = ownsTransaction
? await _context.Database.BeginTransactionAsync(ct)
: null;
try
{
_context.PlayEvents.Add(new PlayEvent
{
TrackEntryKey = trackEntryKey,
ReleaseId = track?.ReleaseId,
Bucket = bucket,
AnonId = anonId,
CreatedAt = DateTime.UtcNow,
});
if (track is not null)
await BumpCounterAsync(track.Id, bucket, ct);
await _context.SaveChangesAsync(ct);
if (transaction is not null)
await transaction.CommitAsync(ct);
return true;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(ct);
throw;
}
finally
{
if (transaction is not null)
await transaction.DisposeAsync();
}
}
/// <summary>
/// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16
/// §5). Sums the mapped columns directly rather than <see cref="PlayCounter.TotalPlays"/>, which is an
/// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home
/// card's expected reading until the telemetry migration is applied).
/// </summary>
public Task<long> CountTotalPlaysAsync(CancellationToken ct = default)
=> _context.PlayCounters
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct);
/// <summary>
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time
/// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage
/// was unavailable) are excluded — they are not a known listener and must not inflate the count. This
/// is the site-wide listener reach figure; the per-track / per-release overloads scope it.
/// </summary>
public Task<int> CountDistinctListenersAsync(CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>
/// Distinct listeners for one track, keyed by its vault entry key (the same key the play event
/// stamps). Null anon ids excluded. Per-track scope of <see cref="CountDistinctListenersAsync()"/>.
/// </summary>
public Task<int> CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>
/// Distinct listeners for one release, derived across the release's tracks (D4): the play event
/// stamps the resolved release id at write time, so a distinct count over <c>anon_id</c> filtered by
/// <c>release_id</c> is exactly "distinct listeners who played any track in this release." Null anon
/// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count
/// over the union, not a sum of per-track counts).
/// </summary>
public Task<int> CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.ReleaseId == releaseId && e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
public async Task RecordShareAsync(
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
CancellationToken ct = default)
{
_context.ShareEvents.Add(new ShareEvent
{
TargetType = targetType,
TargetKey = targetKey,
Channel = channel,
AnonId = anonId,
CreatedAt = DateTime.UtcNow,
});
await _context.SaveChangesAsync(ct);
}
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
// it inside the same transaction as the event append.
//
// Race note: two concurrent first-plays of the same track can both reach this method, find no
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
// unique index is the integrity backstop.
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
{
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
if (counter is null)
{
counter = new PlayCounter { TrackId = trackId };
_context.PlayCounters.Add(counter);
}
switch (bucket)
{
case PlayBucket.Partial: counter.PartialCount++; break;
case PlayBucket.Sampled: counter.SampledCount++; break;
case PlayBucket.Complete: counter.CompleteCount++; break;
}
}
}
@@ -0,0 +1,172 @@
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);
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
// public surface. The resolved entity still carries its int Id for internal joins (track page).
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
=> await _context.Releases
.Where(r => r.EntryKey == entryKey && !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);
}
}
@@ -3,6 +3,7 @@ using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
@@ -95,6 +96,11 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
if (!string.IsNullOrWhiteSpace(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);
@@ -152,6 +158,70 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
// Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
// All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks
// under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a
// null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the
// total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from
// the result (no present-with-zero row).
public async Task<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
{
var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);
var cutTrackCount = await Query
.CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct);
var cutReleaseTypeCounts = await releases
.Where(r => r.Medium == ReleaseMedium.Cut)
.GroupBy(r => r.ReleaseType)
.Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() })
.ToListAsync(ct);
var mixReleaseCount = await releases
.CountAsync(r => r.Medium == ReleaseMedium.Mix, ct);
var mixRuntimeSeconds = await Query
.Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix)
.SumAsync(t => t.DurationSeconds ?? 0d, ct);
return new HomeStatsDto
{
CutTrackCount = cutTrackCount,
CutReleaseTypeCounts = cutReleaseTypeCounts,
MixReleaseCount = mixReleaseCount,
MixRuntimeSeconds = mixRuntimeSeconds,
};
}
// EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list
// the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled
// at runtime: this lists which rows still need it, the backfill reads each from the vault and writes
// it back via UpdateDurationAsync.
public async Task<List<TrackEntity>> GetTracksMissingDurationAsync(CancellationToken ct = default)
=> await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct);
// Set-based duration write for one track (no load round-trip), used by the backfill. The
// DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row
// and from clobbering a value the upload path may have set in the meantime.
public async Task<int> UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
=> await Query
.Where(t => t.Id == id && t.DurationSeconds == null)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
// Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio
// path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the
// existing value because a normally-uploaded track already has a non-null DurationSeconds and the
// null-guarded backfill query would match zero rows and silently leave it stale. Returns the count
// of rows affected; zero means the track was removed between the GetById lookup and this write.
public async Task<int> SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
=> await Query
.Where(t => t.Id == id)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
// signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
@@ -206,6 +276,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
target.TrackName = source.TrackName;
target.TrackNumber = source.TrackNumber;
target.OriginalFileName = source.OriginalFileName;
target.DurationSeconds = source.DurationSeconds;
target.ReleaseId = source.ReleaseId;
}
}
+32 -2
View File
@@ -1,5 +1,6 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Models.Converters;
namespace DeepDrftData;
@@ -18,28 +19,55 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
public static ReleaseDto Convert(ReleaseEntity entity) => new()
{
Id = entity.Id,
EntryKey = entity.EntryKey,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Title = entity.Title,
Artist = entity.Artist,
Genre = entity.Genre,
Description = entity.Description,
ReleaseDate = entity.ReleaseDate,
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
};
public static ReleaseEntity Convert(ReleaseDto dto) => new()
{
Id = dto.Id,
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
// natural-key (Title/Artist) override there.
EntryKey = dto.EntryKey,
CreatedAt = dto.CreatedAt,
UpdatedAt = dto.UpdatedAt,
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate,
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
};
@@ -52,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = entity.TrackName,
OriginalFileName = entity.OriginalFileName,
TrackNumber = entity.TrackNumber,
DurationSeconds = entity.DurationSeconds,
ReleaseId = entity.ReleaseId,
Release = entity.Release is null ? null : Convert(entity.Release)
};
@@ -68,6 +97,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = model.TrackName,
OriginalFileName = model.OriginalFileName,
TrackNumber = model.TrackNumber,
DurationSeconds = model.DurationSeconds,
ReleaseId = model.ReleaseId
};
}
+92 -10
View File
@@ -3,6 +3,7 @@ using Data.Managers;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using Models.Common;
using NetBlocks.Models;
@@ -163,40 +164,62 @@ public class TrackManager
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData);
entity.Id = 0;
// Mint the public EntryKey app-side at creation — the identical call tracks make in
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
entity.EntryKey = Guid.NewGuid().ToString();
entity.Title = title;
entity.Artist = artist;
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row. Should not return null here since the
// constraint just fired, but re-throw if it does so the caller sees an error.
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
}
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
}
}
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);
}
}
@@ -213,6 +236,61 @@ public class TrackManager
}
}
public async Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default)
{
try
{
var stats = await Repository.GetHomeStatsAsync(cancellationToken);
return ResultContainer<HomeStatsDto>.CreatePassResult(stats);
}
catch (Exception e)
{
return ResultContainer<HomeStatsDto>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default)
{
try
{
var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken);
return ResultContainer<List<TrackDto>>.CreatePassResult(
entities.Select(TrackConverter.Convert).ToList());
}
catch (Exception e)
{
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken);
return ResultContainer<int>.CreatePassResult(updated);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken);
if (affected == 0)
return ResultContainer<int>.CreateFailResult($"Duration write matched no rows for track {id}.");
return ResultContainer<int>.CreatePassResult(affected);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{
try
@@ -224,13 +302,13 @@ public class TrackManager
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
if (!resolved.Success || resolved.Value is null)
if (!resolved.Success)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
newTrack.ReleaseId = resolved.Value.Release.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
@@ -261,9 +339,13 @@ public class TrackManager
releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre;
releaseEntity.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate;
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;
await Repository.UpdateReleaseAsync(releaseEntity);
}
+1
View File
@@ -13,6 +13,7 @@
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
<ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
<meta name="robots" content="noindex,nofollow" />
<HeadOutlet @rendermode="ServerMode" />
</head>
@@ -0,0 +1,40 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Small"
Class="d-flex flex-column justify-center align-center"
Style="min-height: calc(100vh - 48px);">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
@using AuthBlocksWeb.Components.Layout
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
@@ -8,16 +9,48 @@
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
<MudSpacer />
<MudTooltip Text="Back to site">
<MudTooltip Text="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
Href="/"
Href="/catalogue"
Color="Color.Inherit" />
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" Variant="DrawerVariant.Responsive" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="/catalogue" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Catalogue</MudNavLink>
<MudNavLink Href="/releases" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LibraryMusic">Releases</MudNavLink>
<MudNavLink Href="/tracks/upload" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Upload</MudNavLink>
<UserAdminMenu />
<HierarchicalRoleAuthorizeView RolesList="@([SystemRoleConstants.UserAdmin])">
<Authorized>
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
</Authorized>
</HierarchicalRoleAuthorizeView>
<AccountNavMenu />
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="pt-14 pb-8">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
@@ -25,6 +58,12 @@
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
+1 -1
View File
@@ -1,6 +1,6 @@
@page "/404"
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
<MudText Typo="Typo.h1" Color="Color.Primary">
404 - Resource Not Found
@@ -0,0 +1,30 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep DRFT Management</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
<RedirectToCatalogue />
</Authorized>
<NotAuthorized>
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
<MudImage Fluid="true" Src="img/cms-hero.webp" Alt="Deep Drft" />
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
Catalogue Management
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Href="@LoginHref"
Class="mt-4"
Style="min-width: 200px;">
Login
</MudButton>
</MudStack>
</NotAuthorized>
</HierarchicalRoleAuthorizeView>
@code {
private static readonly string LoginHref =
$"/account/login?returnUrl={Uri.EscapeDataString("catalogue")}";
}
+50 -72
View File
@@ -1,125 +1,103 @@
@page "/"
@page "/catalogue"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@layout Layout.CmsLayout
@inject NavigationManager Nav
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
<MudGrid Spacing="4">
<MudItem xs="12" sm="4">
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
</MudItem>
@foreach (var card in Cards)
{
<MudItem xs="12" sm="4">
@SummaryCard(card)
</MudItem>
}
</MudGrid>
</MudContainer>
@code {
private bool _tracksLoading = true;
private bool _albumsLoading = true;
private bool _genresLoading = true;
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
private int? _trackCount;
private int? _albumCount;
private int? _genreCount;
private static readonly IReadOnlyList<MediumCard> Cards = new[]
{
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
};
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
// read (pageSize 1) for its TotalCount, run concurrently.
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
protected override async Task OnInitializedAsync()
{
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
// finally block so its card updates as soon as its own fetch returns.
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
// fetch returns, rather than blocking on the slowest of the three.
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
}
private async Task LoadTrackCount()
private async Task LoadCountAsync(ReleaseMedium medium)
{
try
{
var result = await CmsTrackService.GetTrackCountAsync();
_trackCount = result.Success ? result.Value : null;
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
// immaterial to the count.
var result = await CmsReleaseService.GetPagedAsync(
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard track count failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_tracksLoading = false;
_loading.Remove(medium);
StateHasChanged();
}
}
private async Task LoadAlbumCount()
{
try
{
var result = await CmsTrackService.GetReleasesAsync();
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard album summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_albumsLoading = false;
StateHasChanged();
}
}
private async Task LoadGenreCount()
{
try
{
var result = await CmsTrackService.GetGenreSummariesAsync();
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_genresLoading = false;
StateHasChanged();
}
}
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
private RenderFragment SummaryCard(MediumCard card) => __builder =>
{
var loading = _loading.Contains(card.Medium);
var count = _counts.GetValueOrDefault(card.Medium);
<MudCard Elevation="8" Style="height: 100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
@if (loading)
{
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
}
else
{
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
}
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
</MudStack>
</MudCardContent>
<MudCardActions Class="justify-center pb-4">
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo("/tracks"))">
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
View
</MudButton>
</MudCardActions>
</MudCard>
};
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
// Add Track buttons use; the Releases page reads it to set the active tab.
private static string ReleasesHref(ReleaseMedium medium) =>
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
}
@@ -1,12 +1,11 @@
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@inject IHttpClientFactory HttpClientFactory
<MudPaper Class="pa-6 mb-4" Elevation="2">
<MudGrid>
<MudItem xs="12" sm="6">
<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" />
</MudItem>
<MudItem xs="12" sm="6">
@@ -23,14 +22,10 @@
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" />
</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 xs="12">
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
T="string" Label="Description" Lines="4" MaxLength="4000"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
@@ -71,6 +66,15 @@
</MudField>
</MudItem>
</MudGrid>
<MudDivider Class="my-4" />
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
</MudPaper>
@code {
@@ -80,31 +84,50 @@
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; }
[Parameter] public string Description { get; set; } = string.Empty;
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[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 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.
// The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; }
[Parameter] public bool Disabled { get; set; }
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses.
private string? ExistingImagePreviewUrl
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private string? ExistingImagePreviewUrl =>
string.IsNullOrEmpty(ExistingImagePath)
? null
: $"/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
{
if (string.IsNullOrEmpty(ExistingImagePath)) return null;
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
return baseAddress is null
? null
: new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString();
}
get => Medium;
set => MediumChanged.InvokeAsync(value);
}
private ReleaseType ReleaseTypeBinding
{
get => ReleaseType;
set => ReleaseTypeChanged.InvokeAsync(value);
}
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
@@ -1,4 +1,5 @@
@page "/tracks/album/{AlbumName}/edit"
@page "/tracks/{TrackId:long}/edit"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@@ -7,14 +8,13 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject ILogger<BatchEdit> Logger
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
<PageTitle>Edit Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
@@ -32,8 +32,11 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
ExistingImagePath="_existingImagePath"
Disabled="_saving" />
@@ -51,21 +54,31 @@
</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>
<MudItem xs="12" md="5">
@* ExistingTrackCount counts edit-session persisted rows (Id.HasValue), not authoritative
live release count — acceptable because this gate only hides a UI control; the
TrySoftDeleteEmptyReleaseAsync backstop remains the authoritative guard. *@
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_saving"
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
OnRemove="RemoveRow"
OnReplaceFileSelected="HandleReplaceFileSelected" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_saving"
ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
@@ -78,7 +91,7 @@
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
OnClick="@(() => Navigation.NavigateTo("/releases"))"
Disabled="_saving">
Cancel
</MudButton>
@@ -101,11 +114,18 @@
</MudContainer>
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 1_073_741_824L;
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 2_000_000_000L;
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
[Parameter] public string AlbumName { get; set; } = string.Empty;
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
// the track they clicked rather than on the release with no row context. Null for the
// release-title route. The two routes are mutually exclusive — only one segment binds.
[Parameter] public long? TrackId { get; set; }
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _loading = true;
@@ -122,17 +142,64 @@
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The id of the release being edited. New tracks added in this session attach to it via the upload
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
private long? _releaseId;
// 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()
{
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
// then fall through to the shared release-load path below. The clicked track's id is held
// for row pre-selection once the list is built.
var albumName = AlbumName;
if (TrackId is { } trackId)
{
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
if (!trackResult.Success || trackResult.Value is not { } track)
{
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
_loading = false;
return;
}
albumName = track.Release?.Title;
if (string.IsNullOrEmpty(albumName))
{
_loadError = "This track has no parent release to edit.";
_loading = false;
return;
}
}
// A single page of 100 covers the full release (albums are small — same assumption as
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
var result = await CmsTrackService.GetPagedAsync(
page: 1, pageSize: 100,
sortColumn: "TrackNumber", sortDescending: false,
album: AlbumName);
album: albumName);
if (!result.Success || result.Value is null)
{
@@ -144,17 +211,23 @@
var tracks = result.Value.Items.ToList();
if (tracks.Count == 0)
{
_loadError = $"No tracks found for release '{AlbumName}'.";
_loadError = $"No tracks found for release '{albumName}'.";
_loading = false;
return;
}
var release = tracks[0].Release;
_albumName = AlbumName;
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
_releaseId = release?.Id ?? tracks[0].ReleaseId;
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
_existingImagePath = release?.ImagePath;
_tracks = tracks.Select(t => new BatchRowModel
@@ -168,10 +241,31 @@
Status = BatchRowStatus.Queued
}).ToList();
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
// 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);
}
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
_selectedIndex = ResolveInitialSelection();
_loading = false;
}
private int ResolveInitialSelection()
{
if (_tracks.Count == 0) return -1;
if (TrackId is { } trackId)
{
var addressed = _tracks.FindIndex(t => t.Id == trackId);
if (addressed >= 0) return addressed;
}
return 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
@@ -240,6 +334,85 @@
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
}
private async Task HandleReplaceFileSelected((int Index, IBrowserFile File) picked)
{
var (index, file) = picked;
if (index < 0 || index >= _tracks.Count) return;
var row = _tracks[index];
if (!row.Id.HasValue)
{
// Defensive: replace is only offered on persisted rows. A new row would have no track to
// swap against — it takes the upload path on save instead.
return;
}
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
var confirmed = await DialogService.ShowMessageBox(
"Replace audio",
$"Replace the audio for '{row.TrackName}' with '{file.Name}'? " +
"Metadata stays the same; the waveform is regenerated for the new audio.",
yesText: "Replace", cancelText: "Cancel");
if (confirmed != true) return;
row.Status = BatchRowStatus.Uploading;
row.UploadedBytes = 0;
row.TotalBytes = file.Size;
row.ErrorMessage = null;
StateHasChanged();
try
{
await using var wavStream = file.OpenReadStream(MaxUploadBytes);
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var result = await CmsTrackService.ReplaceTrackAudioAsync(
row.Id.Value, wavStream, file.Size, file.Name, file.ContentType, progress);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = error;
Snackbar.Add($"Replace failed: {error}", Severity.Error);
}
else
{
// Reset to Queued (not Done): a Done row is skipped by SaveAsync, but the admin may
// still want to save pending metadata edits. The audio swap is already persisted.
row.Status = BatchRowStatus.Queued;
row.OriginalFileName = file.Name;
Snackbar.Add($"Replaced audio for '{row.TrackName}'.", Severity.Success);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Replace audio failed for track id {Id}", row.Id);
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = "Replace failed — please try again.";
Snackbar.Add("Replace failed — please try again.", Severity.Error);
}
finally
{
StateHasChanged();
}
}
private void RemoveCover()
{
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
@@ -254,7 +427,7 @@
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Album Name is required.";
_errorMessage = "Release Name is required.";
return;
}
@@ -302,6 +475,15 @@
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
// 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.
_saving = true;
@@ -336,7 +518,10 @@
{
var row = _tracks[i];
if (row.Status == BatchRowStatus.Done)
// Skip rows already processed in a prior submit attempt. PostProcessing counts as processed:
// the track persisted successfully (only its background Opus derive is still settling), so a
// re-submit after a partial failure must NOT re-upload it and mint a duplicate.
if (row.Status is BatchRowStatus.Done or BatchRowStatus.PostProcessing)
{
_processedCount++;
continue;
@@ -358,9 +543,11 @@
_artist,
album,
genre,
description,
releaseDate,
imagePathForUpdate,
_releaseType,
_medium,
trackNumber);
if (!updateResult.Success)
@@ -382,20 +569,41 @@
{
// New track — upload, then link cover art with a follow-up update (same
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Re-render only on whole-percent change so a large upload paints ~100 frames,
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var uploadResult = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
album,
genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
trackNumber,
_medium,
_releaseId,
progress);
if (!uploadResult.Success || uploadResult.Value is null)
{
@@ -418,20 +626,27 @@
_artist,
album,
genre,
description,
releaseDate,
linkPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track persisted; cover can be linked via TrackEdit.
// Non-blocking: track persisted; cover can be re-linked by re-editing.
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
row.TrackName, uploadResult.Value.Id);
}
}
row.Status = BatchRowStatus.Done;
// §3.1a: a new-track upload persists the track (live + lossless) and the server
// derives Opus in the background, so the row enters the visible Post-Processing
// phase — same as BatchUpload. (The metadata-only update path above stays Done: it
// changes no audio, so it triggers no transcode.) Non-blocking; the Releases view
// polls the durable Opus status to settle it.
row.Status = BatchRowStatus.PostProcessing;
succeeded++;
}
}
@@ -448,15 +663,10 @@
StateHasChanged();
}
// Either branch changed catalogue data, so the browse caches are stale regardless of
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
// album and genre lists re-fetch.
VM.Invalidate();
if (failed == 0)
{
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
Navigation.NavigateTo("/tracks/albums");
Navigation.NavigateTo("/releases");
}
else
{
@@ -27,6 +27,22 @@ public class BatchRowModel
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
public string? ErrorMessage { get; set; }
/// <summary>Bytes pushed to the wire so far for this row's in-flight upload. Reset per attempt.</summary>
public long UploadedBytes { get; set; }
/// <summary>Total payload bytes for this row (the WAV file size), the progress denominator.</summary>
public long TotalBytes { get; set; }
/// <summary>Upload completion as a 0100 percent, or 0 when the total is unknown.</summary>
public int UploadPercent => TotalBytes > 0
? (int)Math.Clamp(UploadedBytes * 100 / TotalBytes, 0, 100)
: 0;
}
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
// Done is the terminal success state (track persisted + playable losslessly). PostProcessing is the
// visible third upload phase (§3.1a): the byte transfer and server persist are finished and the track is
// live, but the server-side background Opus transcode is still running. It is NOT a failure and never
// blocks completion — the form may navigate away while a row sits in PostProcessing; the Releases browse
// view polls the durable Opus status from there.
public enum BatchRowStatus { Queued, Uploading, PostProcessing, Done, Failed }
@@ -5,20 +5,23 @@
else
{
<MudStack Spacing="4">
<MudTextField Value="SelectedTrack.TrackName"
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
T="string"
Label="Track Name"
Required="true"
RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="Disabled" />
@if (ShowTrackName)
{
<MudTextField Value="SelectedTrack.TrackName"
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
T="string"
Label="Track Name"
Required="true"
RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="Disabled" />
}
@if (SelectedTrack.Id.HasValue)
{
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">Use the Replace audio action in the list to swap this track's audio.</MudText>
</MudField>
}
else
@@ -46,6 +49,12 @@ else
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
[Parameter] public bool Disabled { 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)
{
@@ -34,13 +34,45 @@
Disabled="@(index == Tracks.Count - 1 || Disabled)"
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
aria-label="Move track down" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Disabled="@Disabled"
OnClick="@(() => OnRemove.InvokeAsync(index))"
aria-label="Remove track" />
@* Replace audio: existing (persisted) rows only. New rows still pick their WAV
via the file input above, so a replace control there would be redundant. A
native <label for> drives a per-row hidden InputFile — clicking the icon
opens that row's picker with zero JS (no eval, no programmatic .click()). *@
@if (row.Id.HasValue)
{
<label for="@ReplaceInputId(index)" @onclick:stopPropagation="true"
style="display: inline-flex; @(Disabled ? "pointer-events: none; opacity: 0.5;" : "cursor: pointer;")">
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz"
Color="Color.Primary"
Size="Size.Small"
aria-label="Replace audio" />
</label>
<InputFile id="@ReplaceInputId(index)"
OnChange="@(e => OnReplaceFileSelected.InvokeAsync((index, e.File)))"
accept=".wav,audio/wav,audio/x-wav"
disabled="@Disabled"
style="display: none;" />
}
@* Remove: hidden for the sole remaining persisted track so a release can never
be track-deleted down to zero (that path soft-deletes the whole release). New
rows are always removable — dropping one only discards a pending upload. *@
@if (!(row.Id.HasValue && ExistingTrackCount <= 1))
{
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Disabled="@Disabled"
OnClick="@(() => OnRemove.InvokeAsync(index))"
aria-label="Remove track" />
}
</MudStack>
@if (row.Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@row.UploadPercent"
Class="mx-2 mb-2"
aria-label="@($"Uploading {row.TrackName}")" />
}
</div>
}
</MudList>
@@ -53,11 +85,28 @@
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowNewTracks { get; set; } = true;
/// <summary>
/// Count of existing (persisted, Id-bearing) tracks in the list. When this is 1, the remove
/// control on the sole persisted row is suppressed so a release cannot be track-deleted to zero
/// (replace + release-level delete remain). New unsaved rows are excluded from this count.
/// </summary>
[Parameter] public int ExistingTrackCount { get; set; }
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
[Parameter] public EventCallback<int> OnRemove { get; set; }
/// <summary>
/// Raised when the admin picks a replacement WAV for an existing row, carrying the list index and
/// the chosen file. Only fired for persisted (Id-bearing) rows.
/// </summary>
[Parameter] public EventCallback<(int Index, IBrowserFile File)> OnReplaceFileSelected { get; set; }
// Stable per-row DOM id linking the swap-icon <label> to its hidden InputFile.
private static string ReplaceInputId(int index) => $"replace-audio-input-{index}";
private const int MaxFilesPerPick = 50;
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
@@ -77,6 +126,8 @@
{
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
BatchRowStatus.PostProcessing => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Post-Processing</MudChip>,
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
@@ -6,13 +6,13 @@
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<PageTitle>Upload Release — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
@@ -20,39 +20,82 @@
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" />
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_uploading"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
@if (_medium == ReleaseMedium.Cut)
{
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_uploading"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</MudGrid>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</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>
@if (_tracks[0].Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@_tracks[0].UploadPercent"
aria-label="Uploading track" />
}
else if (_tracks[0].Status == BatchRowStatus.PostProcessing)
{
@* §3.1a: track is live + plays lossless; the Opus transcode runs in the background.
Indeterminate (no client-side progress for a server-side job) and non-blocking. *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<MudText Typo="Typo.caption">Post-Processing (deriving Opus)…</MudText>
</MudStack>
}
}
</MudStack>
</MudPaper>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
<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">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
OnClick="@(() => Navigation.NavigateTo("/releases"))"
Disabled="_uploading">
Cancel
</MudButton>
@@ -74,24 +117,104 @@
</MudContainer>
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 1_073_741_824L;
private const long MaxUploadBytes = 2_000_000_000L;
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _uploading;
private int _uploadedCount;
private string? _errorMessage;
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
private string? _warningMessage;
private IBrowserFile? _selectedImageFile;
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;
// Captured once at component initialization on the live interactive circuit, while the token
// is known-good, so a mid-session token expiry at submit time cannot discard a long-composed
// release. Only assigned when the id parses successfully.
private long? _createdByUserId;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
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);
}
}
protected override async Task OnInitializedAsync()
{
// Capture the user id once at load, while the token is known-good. The CMS host runs with
// prerender: false (InteractiveServer), so this is the single init pass — auth state is
// fully available. The page is [Authorize]-gated, so the parse should always succeed.
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (long.TryParse(userIdValue, out var userId))
{
_createdByUserId = userId;
}
}
// 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)
{
@@ -143,10 +266,11 @@
private async Task SubmitAsync()
{
_errorMessage = null;
_warningMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Album Name is required.";
_errorMessage = "Release Name is required.";
return;
}
@@ -178,17 +302,58 @@
}
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var createdByUserId))
if (_createdByUserId is not long createdByUserId)
{
// The page is gated by [Authorize] under the Admin role, so a missing or
// unparseable id here is a configuration bug, not normal client state.
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
// _createdByUserId is set at component initialization from the authenticated principal.
// A null here means the id was unavailable even at load — a genuine configuration bug,
// since the page is [Authorize]-gated.
Logger.LogError("User id was not captured at initialization — NameIdentifier claim missing or unparseable.");
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
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;
}
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
// the admin is not surprised at the end of a long upload. The server backstops this on the
// create path, but checking here keeps the failure fast and visible. The values passed match
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
if (!duplicateCheck.Success)
{
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Could not verify the release name: {checkError}";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
if (duplicateCheck.Value is { } existing)
{
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
Snackbar.Add(_errorMessage, Severity.Error);
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.
_uploading = true;
_uploadedCount = 0;
@@ -211,6 +376,11 @@
}
int succeeded = 0, failed = 0;
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
// reaches row 2 (single-track media collapse to one row).
long? batchReleaseId = null;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
@@ -219,25 +389,48 @@
row.Status = BatchRowStatus.Uploading;
StateHasChanged();
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
try
{
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in StreamContent so the whole file is never materialised in
// memory before DeepDrftAPI receives it.
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
// service wraps it in ProgressStreamContent so the whole file is never materialised
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var result = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
trackNumber,
_medium,
batchReleaseId,
progress);
if (!result.Success || result.Value is null)
{
@@ -249,8 +442,17 @@
}
else
{
// Capture the release id created by the first successful row so subsequent rows
// attach to it (the within-batch multi-track Cut path). Only set once — later
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
// which is correct: a release-less upload has no within-batch release to attach to.
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
{
batchReleaseId = createdReleaseId;
}
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
// a follow-up metadata update — same two-step pattern BatchEdit uses.
if (_imagePath is { } imgPath)
{
var linkResult = await CmsTrackService.UpdateAsync(
@@ -259,20 +461,80 @@
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
// Non-blocking: track is persisted; cover art can be re-linked by editing.
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
row.TrackName, result.Value.Id);
}
}
row.Status = BatchRowStatus.Done;
// 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);
}
}
// §3.1a: the byte transfer + server persist are done and the track is live and plays
// losslessly — the upload is successful HERE. The server then derives Opus on a
// background worker, so the row enters the visible Post-Processing phase rather than
// jumping straight to Done. This never blocks: the loop continues and the form may
// navigate away while rows sit in Post-Processing; the Releases view polls the durable
// Opus status to settle each one.
row.Status = BatchRowStatus.PostProcessing;
succeeded++;
}
}
@@ -291,12 +553,17 @@
if (failed == 0)
{
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
VM.Invalidate();
Navigation.NavigateTo("/tracks");
Navigation.NavigateTo("/releases");
}
else
{
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
@@ -1,8 +1,8 @@
@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<CmsAlbumBrowser> Logger
@@ -33,6 +33,10 @@ else
<MudTh>Release Date</MudTh>
<MudTh>Type</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
@foreach (var column in SpecialColumns)
{
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
}
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
@@ -57,9 +61,20 @@ else
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<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 DataLabel="Tracks">@context.TrackCount</MudTd>
@foreach (var column in SpecialColumns)
{
@* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
Tracks and Actions so the universal Edit/Delete stay rightmost. *@
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
}
<MudTd DataLabel="Actions">
<MudTooltip Text="Batch Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
@@ -80,7 +95,7 @@ else
@if (context.IsExpanded)
{
<MudTr>
<MudTd colspan="9" Style="padding: 0;">
<MudTd colspan="@ColumnCount" Style="padding: 0;">
@if (context.IsLoading)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
@@ -99,10 +114,71 @@ else
<HeaderContent>
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
<MudTh>Track Name</MudTh>
@* Per-track waveform-datum status + generate (migrated from the retired
CmsTrackGrid). The expanded child row is the releases view's only
per-track surface, so the unique per-track Profile / High-res columns
live here. *@
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
<MudTh Style="width: 1%;"></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
<MudTd DataLabel="Profile">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (HasProfile(track.EntryKey))
{
<MudTooltip Text="Profile generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@(HasProfile(track.EntryKey) ? "Regenerate profile" : "Generate profile")">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@_generating.Contains(track.EntryKey)"
OnClick="@(() => GenerateProfileAsync(track))" />
</MudTooltip>
</MudStack>
</MudTd>
<MudTd DataLabel="High-res">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (HasHighRes(track.EntryKey))
{
<MudTooltip Text="High-res datum generated">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@(HasHighRes(track.EntryKey) ? "Regenerate high-res datum" : "Generate high-res datum")">
<MudIconButton Icon="@Icons.Material.Filled.Waves"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
OnClick="@(() => GenerateHighResAsync(track))" />
</MudTooltip>
</MudStack>
</MudTd>
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
<MudTd>
<MudTooltip Placement="Placement.Left">
<TooltipContent>
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
{
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
}
</TooltipContent>
<ChildContent>
<MudIconButton Icon="@Icons.Material.Outlined.Info"
Size="Size.Small"
Color="Color.Default" />
</ChildContent>
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
}
@@ -118,24 +194,52 @@ else
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; }
/// <summary>
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
/// current after an individual-row generate inside an expanded album row.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
/// <summary>
/// Clears the cached per-track waveform status so the next row expand re-fetches fresh data
/// from the API. Called by the parent page after a catalogue-wide bulk run so already-expanded
/// rows reflect the new state on the next expand interaction.
/// </summary>
public Task InvalidateWaveformStatusAsync()
{
_profileStatus = null;
_highResStatus = null;
StateHasChanged();
return Task.CompletedTask;
}
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
// columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
// expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
// Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
private const int BaseColumnCount = 9;
// Total rendered columns, driving the expanded child-row colspan so it always spans the full table
// regardless of how many special-action columns the host declared.
private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
private List<AlbumRow> _rows = new();
// 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
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
// back. We only re-project when the parent hands us a genuinely new list.
// resurrecting a row we removed locally on delete: while the parent holds the same Releases
// instance (e.g. a mid-operation re-render under IsDeleting, before any refresh hands us a new
// 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;
// The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated,
// so the browser hits it directly. Base address comes from the same named client the CMS uses.
private Uri? _contentApiBase;
protected override void OnInitialized() =>
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
// 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
// same cached VM.Albums instance.
// same Releases instance.
protected override void OnParametersSet()
{
if (!ReferenceEquals(_projectedReleases, Releases))
@@ -145,10 +249,115 @@ else
}
}
private string? ThumbUrl(string imagePath) =>
_contentApiBase is null
? null
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private static string ThumbUrl(string 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",
};
// EntryKey → HasProfile / HasHighRes for the expanded-row per-track waveform columns (migrated from
// the retired CmsTrackGrid). Loaded once per grid instance on first row expand; a per-row generate
// flips a single entry to true. Null until first loaded.
private Dictionary<string, bool>? _profileStatus;
private Dictionary<string, bool>? _highResStatus;
private readonly HashSet<string> _generating = new();
private readonly HashSet<string> _generatingHighRes = new();
private bool HasProfile(string entryKey) =>
_profileStatus is not null && _profileStatus.TryGetValue(entryKey, out var has) && has;
private bool HasHighRes(string entryKey) =>
_highResStatus is not null && _highResStatus.TryGetValue(entryKey, out var has) && has;
// Fetch the catalogue-wide waveform status once and cache it. The admin catalogue is small (one unpaged
// call covers it), and per-track status only matters for rows the admin actually expands.
private async Task EnsureWaveformStatusAsync()
{
if (_profileStatus is not null) return;
var result = await CmsTrackService.GetWaveformStatusAsync();
if (result.Success && result.Value is not null)
{
_profileStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
}
else
{
// Leave both empty (not null) so we do not re-fetch on every expand after a transient failure;
// the next OnReleasesChanged refresh path will rebuild the grid and retry.
_profileStatus = new Dictionary<string, bool>();
_highResStatus = new Dictionary<string, bool>();
}
}
private async Task GenerateProfileAsync(TrackDto track)
{
_generating.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
if (result.Success)
{
(_profileStatus ??= new())[track.EntryKey] = true;
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
await OnWaveformGenerated.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generating.Remove(track.EntryKey);
StateHasChanged();
}
}
private async Task GenerateHighResAsync(TrackDto track)
{
_generatingHighRes.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
if (result.Success)
{
(_highResStatus ??= new())[track.EntryKey] = true;
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
await OnWaveformGenerated.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generatingHighRes.Remove(track.EntryKey);
StateHasChanged();
}
}
private async Task ToggleExpand(AlbumRow row)
{
@@ -158,6 +367,9 @@ else
row.IsLoading = true;
StateHasChanged();
row.Tracks = await LoadTracksAsync(row.Release.Title);
// The per-track Profile / High-res columns need waveform status for the rows just loaded.
// Loaded once for the catalogue on first expand and cached for this grid instance.
await EnsureWaveformStatusAsync();
row.IsLoading = false;
}
}
@@ -240,7 +452,7 @@ else
// 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
// 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)
{
var confirmed = await DialogService.ShowMessageBox(
@@ -0,0 +1,71 @@
@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 @ref="_albumBrowser"
Releases="_releases"
IsLoading="_loading"
OnReleasesChanged="OnGridReleasesChanged"
OnWaveformGenerated="OnWaveformGenerated" />
@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; }
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
private bool _loading = true;
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
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,41 @@
@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 action, so no SpecialColumns are supplied; the grid renders
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
<CmsAlbumBrowser @ref="_albumBrowser"
Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync"
OnWaveformGenerated="OnWaveformGenerated" />
@code {
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
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;
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
public sealed class CutRow
{
public required ReleaseDto Release { get; set; }
}
}
@@ -1,52 +0,0 @@
@using DeepDrftModels.DTOs
@if (IsLoading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (Genres.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
}
else
{
<MudGrid Spacing="3" Class="mt-2">
@foreach (var genre in Genres)
{
var isExpanded = ExpandedGenre == genre.Genre;
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="@(isExpanded ? 4 : 1)"
Style="cursor: pointer;"
@onclick="@(() => ToggleGenre(genre.Genre))">
<div class="@SwatchClass(isExpanded)"></div>
<MudCardContent>
<MudText Typo="Typo.h6">@genre.Genre</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@if (ExpandedGenre is not null)
{
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
}
}
@code {
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string? ExpandedGenre { get; set; }
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
private async Task ToggleGenre(string genre) =>
await OnExpandedGenreChanged.InvokeAsync(genre);
private static string SwatchClass(bool isExpanded) =>
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
}
@@ -1,10 +0,0 @@
.cms-genre-swatch {
width: 100%;
height: 80px;
background-color: var(--mud-palette-action-default-hover);
transition: background-color 0.2s ease;
}
.cms-genre-swatch--active {
background-color: var(--mud-palette-primary-hover);
}
@@ -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>SpecialColumns</c> column model, 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 SpecialColumns cell delegate (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,168 @@
@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 special-action column 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 — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.ArrowBack"
Href="/releases"
Class="mb-4">
Back to Releases
</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; }
/// <summary>
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
/// </summary>
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
private CmsAlbumBrowser? _albumBrowser;
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
protected override string MediumNoun => "mixes";
/// <summary>
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
/// Called by the parent page after a catalogue-wide bulk run.
/// </summary>
public Task InvalidateWaveformStatusAsync() =>
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
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 Mix declares one dedicated
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
// each release, and RowFor recovers the matching MixRow's generate state.
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
Releases="Releases"
IsLoading="Loading"
OnReleasesChanged="ReloadAsync"
OnWaveformGenerated="OnWaveformGenerated"
SpecialColumns="_specialColumns" />;
// Allocated once per component instance in OnInitialized (field initializers cannot reference
// instance members, so initialization is deferred to the first lifecycle hook).
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
protected override void OnInitialized()
{
_specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
base.OnInitialized();
}
// Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
// progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
private RenderFragment<ReleaseDto> WaveformCell => release =>@<text>
@{ var row = RowFor(release); }
@if (row is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@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>
</MudStack>
}
</text>;
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; }
}
}

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