221 Commits

Author SHA1 Message Date
daniel aa4fae1faf Merge pull request 'Merge - Streaming Winner' (#2) from dev into master
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m16s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m27s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 20s
Deploy DeepDrftManager / Deploy (push) Successful in 12s
Deploy DeepDrftPublic / Deploy (push) Successful in 12s
Reviewed-on: #2
2026-06-27 02:47:43 +00:00
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 d26c11e897 Merge pull request 'chore: Trigger CI' (#1) from dev into master
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m20s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m31s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m24s
Package install tarball / package (push) Successful in 7s
Deploy DeepDrftAPI / Deploy (push) Successful in 22s
Deploy DeepDrftManager / Deploy (push) Successful in 13s
Deploy DeepDrftPublic / Deploy (push) Successful in 15s
Reviewed-on: #1
2026-06-23 12:51:08 +00: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
231 changed files with 20820 additions and 1223 deletions
+4 -2
View File
@@ -317,5 +317,7 @@ Database/Vaults/*
!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.
!DeepDrftShared.Client/wwwroot/js/parallax/
!DeepDrftShared.Client/wwwroot/js/knob/
# 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/**
+34 -12
View File
@@ -8,9 +8,9 @@ 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). 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`. 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). 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`. 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. 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.
- **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). 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.
@@ -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,36 +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).
- **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. 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`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
**`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
@@ -126,7 +148,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
- `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`). 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. 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).
- `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.
+197 -1
View File
@@ -6,6 +6,202 @@ 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.
@@ -128,7 +324,7 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
- **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/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
- **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.
+26 -15
View File
@@ -11,12 +11,12 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
## 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`).
- `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": "..." } }`).
@@ -32,12 +32,12 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
### 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)
@@ -74,7 +74,7 @@ Admin backfill: computes and stores a waveform profile for an existing track fro
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
- 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)
@@ -91,7 +91,7 @@ Server-side trigger: compute and store the per-track high-res datum for any trac
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`.
- 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)
@@ -110,6 +110,15 @@ Admin backfill view: returns every track with flags indicating whether each wave
- **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.
@@ -168,9 +177,9 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- `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 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.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 temp file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). 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.
- 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])
@@ -189,8 +198,8 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- **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 temp file (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)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → 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).
- `[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)
@@ -288,7 +297,7 @@ Legacy endpoint: formerly served the high-res waveform datum for a Mix release f
### 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.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body.
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.
@@ -379,6 +388,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
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):
@@ -401,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):
+4 -4
View File
@@ -112,10 +112,10 @@ public class ReleaseController : ControllerBase
}
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
// waveform via ComputeAndStoreHighResAsync, 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}".
// 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)
+255 -63
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,9 +46,49 @@ 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.
@@ -204,6 +249,40 @@ public class TrackController : ControllerBase
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
@@ -224,6 +303,27 @@ public class TrackController : ControllerBase
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.
@@ -319,23 +419,15 @@ public class TrackController : ControllerBase
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,
@@ -381,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);
}
}
@@ -567,21 +649,14 @@ public class TrackController : ControllerBase
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
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.ReplaceAudioAsync(id, tempPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
@@ -604,17 +679,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -636,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
{
@@ -658,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
@@ -672,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.
@@ -694,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
@@ -750,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.");
@@ -778,16 +933,27 @@ public class TrackController : ControllerBase
[HttpPost("{trackId}/waveform/high-res")]
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
// 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();
}
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, trackId, audio.Duration);
if (!stored)
// 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.");
@@ -796,6 +962,32 @@ public class TrackController : ControllerBase
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)
+2 -1
View File
@@ -15,7 +15,7 @@
<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>
@@ -32,3 +32,4 @@
</Project>
+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);
}
+13 -2
View File
@@ -4,6 +4,7 @@ using DeepDrftAPI;
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Models;
using DeepDrftAPI.Services;
using DeepDrftAPI.Services.Opus;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
@@ -66,6 +67,13 @@ 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.
@@ -103,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
{
@@ -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);
}
}
+15 -6
View File
@@ -143,8 +143,9 @@ public class UnifiedReleaseService
return Result.CreateFailResult(MixHasNoTrackMessage);
}
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
// 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);
@@ -152,10 +153,18 @@ public class UnifiedReleaseService
// 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).
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, entryKey, audio.Duration);
if (!computed)
// 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.");
+105 -26
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;
@@ -39,6 +41,8 @@ public class UnifiedTrackService
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(
@@ -46,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;
}
@@ -138,7 +146,7 @@ public class UnifiedTrackService
}
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)
{
@@ -219,6 +227,11 @@ public class UnifiedTrackService
// frontend, so a failure here is logged and swallowed — never fails the upload.
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;
}
@@ -269,31 +282,25 @@ public class UnifiedTrackService
var entryKey = lookup.Value.EntryKey;
var newAudio = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath);
if (newAudio is null)
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 (proven re-runnable). The
// freshly stored buffer is the authoritative source — no re-read of the vault needed.
try
{
await _waveformProfileService.ComputeAndStoreAsync(newAudio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey);
}
// 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, newAudio.Duration, ct);
var durationWrite = await _sqlTrackService.SetDuration(trackId, newDuration.Value, ct);
if (!durationWrite.Success)
{
var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
@@ -303,20 +310,26 @@ public class UnifiedTrackService
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 source the same
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
// 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 audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
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.",
@@ -324,8 +337,8 @@ public class UnifiedTrackService
return;
}
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
await _waveformProfileService.ComputeAndStoreAllStreamingAsync(
_ => _contentTrackContentService.OpenAudioStreamAsync(entryKey), entryKey, duration.Value, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -356,8 +369,11 @@ public class UnifiedTrackService
{
ct.ThrowIfCancellationRequested();
var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
if (audio is null)
// 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);
@@ -365,7 +381,7 @@ public class UnifiedTrackService
continue;
}
var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
var write = await _sqlTrackService.UpdateDuration(track.Id, duration.Value, ct);
if (!write.Success)
{
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
@@ -381,6 +397,69 @@ public class UnifiedTrackService
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
+59
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;
@@ -44,12 +45,60 @@ namespace DeepDrftAPI
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))
@@ -75,5 +124,15 @@ namespace DeepDrftAPI
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",
+55 -16
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,19 +74,33 @@ 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.
- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute.
- `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).
@@ -101,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()
@@ -122,7 +161,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"`, `VaultConstants.Images = "images"`, and `VaultConstants.TrackWaveforms = "track-waveforms"` — 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). 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
@@ -28,4 +28,13 @@ public static class VaultConstants
/// 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));
}
@@ -18,100 +18,27 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
/// </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];
if (channels <= 0)
{
return result;
}
var bytesPerSample = bitsPerSample / 8;
if (bytesPerSample <= 0)
{
return result;
}
var bytesPerFrame = bytesPerSample * channels;
var frameCount = pcmData.Length / bytesPerFrame;
if (frameCount == 0)
{
return result;
}
// 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]++;
}
for (var i = 0; i < bucketCount; i++)
{
if (counts[i] > 0)
{
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
}
}
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
var totalSeconds = (double)frameCount / sampleRate;
var bucketSeconds = totalSeconds / bucketCount;
SmoothEnvelope(result, bucketSeconds);
var peak = 0.0;
for (var i = 0; i < bucketCount; 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;
return new RmsLoudnessAccumulator(pcmByteLength, channels, sampleRate, bitsPerSample, bucketCount);
}
/// <summary>
@@ -122,7 +49,7 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
/// 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>
private static void SmoothEnvelope(double[] data, double bucketSeconds)
internal static void SmoothEnvelope(double[] data, double bucketSeconds)
{
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
{
@@ -154,7 +81,7 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
/// 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)
{
@@ -194,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;
@@ -117,6 +121,161 @@ public class WaveformProfileService
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
+99 -30
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");
@@ -77,7 +82,7 @@ public class TrackContentService
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 = audioBinary.Duration
DurationSeconds = processed.Duration
};
return trackEntity;
@@ -100,34 +105,37 @@ 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 written 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 — the file is
/// written in place. If the register fails the original audio is left intact and null is returned,
/// so the track remains playable. Returns the freshly stored <see cref="AudioBinary"/> on success
/// (so the caller can regenerate waveform data from the same bytes) — matching the FileDatabase
/// swallow-and-return-null contract.
/// 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<AudioBinary?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath)
public async Task<double?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath, CancellationToken cancellationToken = default)
{
try
{
// Capture the old extension before touching the vault. After register the index
// will point 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 existing = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, entryKey);
var oldExtension = existing?.Extension;
// 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 audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
var processed = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath, cancellationToken);
if (processed == null)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
return null;
@@ -138,9 +146,11 @@ public class TrackContentService
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Register the new audio. 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 success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary);
// 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");
@@ -153,7 +163,7 @@ public class TrackContentService
// 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 != audioBinary.Extension)
if (oldExtension != null && oldExtension != processed.Extension)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault != null)
@@ -172,7 +182,7 @@ public class TrackContentService
}
}
return audioBinary;
return processed.Duration;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -191,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>
+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>
+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>
@@ -6,9 +6,23 @@
<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>
<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"
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
@using AuthBlocksWeb.Components.Layout
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
@@ -8,9 +9,27 @@
<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="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
@@ -18,6 +37,20 @@
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
+1 -1
View File
@@ -1,7 +1,7 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep Drft — Admin</PageTitle>
<PageTitle>Deep DRFT Management</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
+1 -1
View File
@@ -7,7 +7,7 @@
@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>
@@ -14,7 +14,7 @@
@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>
@@ -518,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;
@@ -638,7 +641,12 @@
}
}
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++;
}
}
@@ -40,4 +40,9 @@ public class BatchRowModel
: 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 }
@@ -126,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>
@@ -12,7 +12,7 @@
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
<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>
@@ -69,6 +69,15 @@
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>
@@ -129,6 +138,11 @@
// 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;
@@ -156,6 +170,19 @@
}
}
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.
@@ -275,13 +302,12 @@
}
}
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;
}
@@ -502,7 +528,13 @@
}
}
row.Status = BatchRowStatus.Done;
// §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++;
}
}
@@ -18,7 +18,7 @@
}
else
{
<PageTitle>Mixes — DeepDrft CMS</PageTitle>
<PageTitle>Mixes — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -19,7 +19,7 @@
}
else
{
<PageTitle>Sessions — DeepDrft CMS</PageTitle>
<PageTitle>Sessions — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudButton Variant="Variant.Text"
@@ -9,9 +9,10 @@
@inject ISnackbar Snackbar
@inject ILogger<Releases> Logger
@inject NavigationManager NavigationManager
@implements IDisposable
@attribute [Authorize]
<PageTitle>Releases — DeepDrft CMS</PageTitle>
<PageTitle>Releases — Deep DRFT Management</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
@@ -51,6 +52,28 @@
<span>Backfill High-res (@MissingHighResCount)</span>
}
</MudButton>
@* Backfill-Opus (Phase 18.5 + 18.6 badge). The Opus derive runs on a server-side background
worker: pressing the button enqueues every track lacking a complete Opus artifact and reports
the (enqueued / skipped) outcome. The "missing N" badge (18.6) reads the same opus-status map
the page polls, giving visual parity with the two waveform backfill buttons — but unlike them,
the count is informational, not a per-track client loop (the work is scheduled, not driven from
here). The button stays pressable at N=0 (a no-op re-run is harmless); it only disables while a
press is in flight or another bulk run holds the page. *@
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.GraphicEq"
Disabled="@(_bulkRunning || _highResBulkRunning || _opusBackfillRunning)"
OnClick="BackfillOpusAsync">
@if (_opusBackfillRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Scheduling…</span>
}
else
{
<span>Backfill Opus (@MissingOpusCount)</span>
}
</MudButton>
</MudStack>
</MudStack>
@@ -140,6 +163,22 @@
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
// EntryKey → HasOpus (a complete audio+sidecar derive). Loaded alongside the waveform status on init and
// re-read after a Backfill-Opus run so the "missing N" badge settles. Also the source the Post-Processing
// poll watches: a freshly uploaded track lands here with HasOpus=false and flips to true once the
// server-side background transcode finishes — the durable surface for the upload meter's Post-Processing
// phase after the form returns the admin to this view (§3.1a).
private IReadOnlyList<OpusStatusDto> _opusStatus = Array.Empty<OpusStatusDto>();
private int MissingOpusCount => _opusStatus.Count(s => !s.HasOpus);
// Post-Processing poll: while any track is still missing Opus (a transcode in flight or not yet
// backfilled), re-read the opus-status map on an interval so the "missing N" badge and any per-track
// Post-Processing indicator settle without a manual refresh. Stops itself once nothing is missing, and is
// torn down on dispose. Non-blocking — it never gates an upload or a button; it only refreshes a count.
private const int OpusPollIntervalMs = 4000;
private CancellationTokenSource? _opusPollCts;
// Local state for the parent-owned "Generate All Profiles" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
@@ -150,6 +189,11 @@
private int _highResBulkTotal;
private int _highResBulkDone;
// Local state for the "Backfill Opus" action. The Opus derive is server-side and background-queued, so
// there is no client-side per-track loop or progress total — this flag only guards the button while the
// single scheduling call is in flight.
private bool _opusBackfillRunning;
protected override async Task OnInitializedAsync()
{
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
@@ -170,9 +214,81 @@
_waveformStatus = result.Success && result.Value is not null
? result.Value
: Array.Empty<WaveformStatusDto>();
await RefreshOpusStatusAsync();
StateHasChanged();
}
/// <summary>
/// Re-read the per-track Opus derive status and (re)arm the Post-Processing poll when work is still
/// pending. Called on init, after a Backfill-Opus run, and from the poll itself. Best-effort: a failed
/// fetch leaves the previous map in place rather than zeroing the badge on a transient API blip. Does not
/// call StateHasChanged itself — callers batch it with their own render (the poll path renders explicitly).
/// </summary>
private async Task RefreshOpusStatusAsync()
{
var opusResult = await CmsTrackService.GetOpusStatusAsync();
if (opusResult.Success && opusResult.Value is not null)
{
_opusStatus = opusResult.Value;
}
if (MissingOpusCount > 0)
{
EnsureOpusPollRunning();
}
}
// Start the Post-Processing poll if it is not already running. The loop re-reads opus-status every
// OpusPollIntervalMs and renders; it exits as soon as nothing is missing (or on cancel/dispose). Guarded
// by a non-null CTS so overlapping callers (init + backfill) cannot start two loops.
private void EnsureOpusPollRunning()
{
if (_opusPollCts is not null)
{
return;
}
_opusPollCts = new CancellationTokenSource();
_ = PollOpusStatusAsync(_opusPollCts.Token);
}
private async Task PollOpusStatusAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested && MissingOpusCount > 0)
{
await Task.Delay(OpusPollIntervalMs, ct);
var result = await CmsTrackService.GetOpusStatusAsync();
if (result.Success && result.Value is not null)
{
_opusStatus = result.Value;
await InvokeAsync(StateHasChanged);
}
}
}
catch (OperationCanceledException)
{
// Expected on dispose / navigation away — nothing to do.
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Opus Post-Processing poll stopped on an unexpected error.");
}
finally
{
_opusPollCts?.Dispose();
_opusPollCts = null;
}
}
public void Dispose()
{
_opusPollCts?.Cancel();
}
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
// reflect the new waveform state on the next expand interaction.
@@ -291,4 +407,54 @@
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
}
}
/// <summary>
/// Kick off the catalogue-wide Backfill-Opus pass. The API enumerates the tracks lacking a complete Opus
/// artifact, enqueues a background derive for each, and returns the (enqueued, skipped) counts. This is a
/// single scheduling call — the transcodes run server-side afterward — so there is no per-track progress
/// to render here, just a busy flag and a result snackbar. Re-runnable: a second press only schedules
/// tracks still missing Opus.
/// </summary>
private async Task BackfillOpusAsync()
{
_opusBackfillRunning = true;
StateHasChanged();
try
{
var result = await CmsTrackService.BackfillOpusAsync();
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to start the Opus backfill.";
Snackbar.Add(error, Severity.Error);
return;
}
// Re-read the status map so the "missing N" badge reflects the just-enqueued work and the
// Post-Processing poll arms to watch the transcodes settle from N→0 as each finishes.
await RefreshOpusStatusAsync();
var (enqueued, skipped) = (result.Value.Enqueued, result.Value.Skipped);
if (enqueued == 0)
{
Snackbar.Add($"All {skipped} track(s) already have Opus — nothing to backfill.", Severity.Info);
}
else
{
Snackbar.Add(
$"Scheduled {enqueued} Opus transcode(s) in the background ({skipped} already had Opus). " +
"They will appear as each finishes.",
Severity.Success);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Opus backfill failed to start");
Snackbar.Add("Failed to start the Opus backfill.", Severity.Error);
}
finally
{
_opusBackfillRunning = false;
StateHasChanged();
}
}
}
+19 -2
View File
@@ -2,8 +2,8 @@
AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="typeof(Layout.CmsLayout)">
<AuthorizeRouteView RouteData="routeData"
DefaultLayout="@_currentLayout">
<NotAuthorized Context="authState">
@if (authState.User.Identity?.IsAuthenticated == true)
{
@@ -18,3 +18,20 @@
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthenticationState { get; set; }
private Type _currentLayout = typeof(Layout.CmsHomeLayout);
protected override async Task OnParametersSetAsync()
{
if (AuthenticationState is not null)
{
var authState = await AuthenticationState;
_currentLayout = authState.User.Identity?.IsAuthenticated == true
? typeof(Layout.CmsLayout)
: typeof(Layout.CmsHomeLayout);
}
}
}
+2 -1
View File
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="8.15.0" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.33" />
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
@@ -17,3 +17,4 @@
</ItemGroup>
</Project>
@@ -765,6 +765,89 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync("api/track/opus/backfill", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for Opus backfill");
return ResultContainer<OpusBackfillResult>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API Opus backfill failed: {Status} {Body}", (int)response.StatusCode, body);
return ResultContainer<OpusBackfillResult>.CreateFailResult("Failed to start the Opus backfill.");
}
OpusBackfillResult payload;
try
{
payload = await response.Content.ReadFromJsonAsync<OpusBackfillResult>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize Opus backfill response from Content API");
return ResultContainer<OpusBackfillResult>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<OpusBackfillResult>.CreatePassResult(payload);
}
}
public async Task<ResultContainer<OpusStatusDto[]>> GetOpusStatusAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/opus-status", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for Opus status");
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API Opus status failed: {Status}", (int)response.StatusCode);
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Failed to load Opus status.");
}
OpusStatusDto[]? status;
try
{
status = await response.Content.ReadFromJsonAsync<OpusStatusDto[]>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize Opus status from Content API response");
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
}
if (status is null)
{
_logger.LogError("Content API returned a null Opus status list");
return ResultContainer<OpusStatusDto[]>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<OpusStatusDto[]>.CreatePassResult(status);
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -152,6 +152,23 @@ public interface ICmsTrackService
/// </summary>
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
/// <summary>
/// Trigger the catalogue-wide Backfill-Opus pass via <c>POST api/track/opus/backfill</c> (Phase 18.5).
/// The API enqueues a background Opus derive for every track lacking a complete Opus artifact and returns
/// the (enqueued, skipped) counts. Enqueue-only — the transcodes run server-side on a serial background
/// worker, so this call returns as soon as the work is scheduled, not when transcoding finishes. The
/// <c>Enqueued</c> count is how many derives were scheduled; <c>Skipped</c> is how many already had Opus.
/// </summary>
Task<ResultContainer<OpusBackfillResult>> BackfillOpusAsync(CancellationToken ct = default);
/// <summary>
/// Fetch per-track Opus derive status from <c>GET api/track/opus-status</c> (Phase 18.6) for the CMS
/// Post-Processing surfaces. Unpaged — the admin catalogue is small. Each row's <c>HasOpus</c> is true only
/// when the track carries a complete Opus artifact (audio + sidecar). Drives the Backfill-Opus "missing N"
/// badge and the post-upload Post-Processing poll. Idempotent read — safe to poll on an interval.
/// </summary>
Task<ResultContainer<OpusStatusDto[]>> GetOpusStatusAsync(CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
@@ -160,3 +177,11 @@ public interface ICmsTrackService
/// </summary>
Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default);
}
/// <summary>
/// Outcome of a Backfill-Opus pass (Phase 18.5): how many tracks had a background derive scheduled
/// (<paramref name="Enqueued"/>) and how many were skipped because they already carry a complete Opus
/// artifact (<paramref name="Skipped"/>). Both are counts of tracks, not finished transcodes — the work
/// runs asynchronously on the API's background worker after this returns.
/// </summary>
public readonly record struct OpusBackfillResult(int Enqueued, int Skipped);
+1 -2
View File
@@ -1,8 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Warning"
}
},
"AllowedHosts": "*",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+19
View File
@@ -0,0 +1,19 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Per-track Opus derive status for the CMS Post-Processing surfaces (Phase 18.6). Mirrors
/// <see cref="WaveformStatusDto"/>: one row per track, flagging whether the track already carries a
/// <strong>complete</strong> Opus artifact. "Complete" means BOTH the Opus audio bytes AND the seek/setup
/// sidecar are present in the <c>track-opus</c> vault — a half-derived track (audio without sidecar) is
/// unseekable and counts as missing, so the Backfill-Opus pass re-derives it. <see cref="EntryKey"/> is the
/// vault key the per-track enqueue trigger and the polling Post-Processing affordance key on.
/// </summary>
public class OpusStatusDto
{
public long TrackId { get; set; }
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
/// <summary>True only when both the Opus audio and the seek/setup sidecar are stored (a complete derive).</summary>
public bool HasOpus { get; set; }
}
+2 -2
View File
@@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Models" Version="10.3.35" />
</ItemGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
namespace DeepDrftModels.Enums;
/// <summary>
/// The delivery format a listener requests for a track's audio (Phase 18). One <c>TrackEntity</c> /
/// <c>EntryKey</c> addresses both renderings — "one source, multiple views" applied to delivery (C5).
/// Lives here, not in the content library, because it is a cross-boundary contract: the API stream
/// endpoint (18.3) parses it off the <c>?format=</c> query param, the WASM client (18.4 / 18.6) selects
/// it, and the content-side resolver (18.2) resolves it to bytes — all three reference one enum.
/// </summary>
public enum AudioFormat
{
/// <summary>
/// The existing source artifact in the <c>tracks</c> vault, served byte-for-byte with its real MIME
/// (WAV/MP3/FLAC — do not assume WAV). The universal, always-present rendering.
/// </summary>
Lossless,
/// <summary>
/// The derived low-data Ogg Opus 320 artifact in the <c>track-opus</c> vault (<c>audio/ogg</c>). A
/// best-effort derived artifact: when absent (not yet transcoded, or transcode failed) a request for
/// it falls back to <see cref="Lossless"/> rather than 404ing (C2).
/// </summary>
Opus
}
+38 -11
View File
@@ -10,17 +10,18 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"` with `.dd-detail-fill` so the ambient visualizer reads full-screen and the footer is pushed below the fold; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; **Phase 20:** top action row carries `<TheaterModeToggle Available="ShowTheaterToggle" />` immediately left of the lava-lamp popover in a `.dd-detail-top-actions` cluster — the toggle only appears when this page's release is the one currently playing (`ShowTheaterToggle` from `ReleaseDetailBase` folds in the subsystem gate + release-playing check); hero overlay and `<ReleaseDescription>` are wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair that gets `.dd-theater-collapsed` when `IsContentHidden` is true — eased collapse via `grid-template-rows: 1fr → 0fr` + `opacity` + `visibility` (no hard `@if` pop); renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; the foreground container carries `.dd-detail-fill`; renders `<ReleaseDescription>` below the hero for the release's description blurb; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Mix is the playing release; hero overlay and description are wrapped in `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; the scaffold is wrapped in a `.dd-detail-fill` div; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`; **Phase 20:** `TopRightAction` slot holds `<TheaterModeToggle Available="ShowTheaterToggle" />` + lava-lamp popover in a `.dd-detail-top-actions` cluster — toggle only appears when this Cut is the playing release; header and track-list body are each wrapped in a `.dd-theater-collapsible` / `.dd-theater-collapsed` eased collapse driven by `IsContentHidden`, replacing the prior hard `@if`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming — routes through `IQueueService.PlayTrack` (deque PLAY semantics) when the queue cascade is present, falls back to `IStreamingPlayerService.SelectTrackStreaming` when absent. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored). **Phase 20:** injects `WaveformVisualizerControlState` and subscribes to `Changed` (added alongside the existing `IPlayerService.StateChanged` subscription — same reference-guard + dispose pattern); mounts `<NowShowingPanel Release="CurrentTrack.Release" />` above the transport controls when `CurrentTrack?.Release is not null` — the panel is kept **always mounted** whenever a release is playing and wrapped in the shared `.dd-theater-collapsible` / `.dd-theater-collapsible-inner` pair; it gets `.dd-theater-collapsed` when Theater Mode is OFF, so the bar grows/shrinks via the same eased collapse that the detail-page content regions use rather than popping via `@if` (Phase 20 Wave 2).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/NowShowingPanel.razor`: Phase 20 "now showing" presentational band rendered by `AudioPlayerBar` **only when** `VisualizerControlState.TheaterMode && CurrentTrack?.Release is not null`. Carries the release identity the hidden detail page would otherwise show: cover art thumbnail (`deepdrft-track-detail-cover-art` / `deepdrft-gradient-soft-secondary` placeholder), release title linked via `ReleaseRoutes.DetailHref(Release)`, and a release-mode `SharePopover` (`ReleaseEntryKey` + `ReleaseMedium`) wrapped in `.dd-accent-icon`. `[Parameter, EditorRequired] ReleaseDto Release` — non-null by the bar's mount gate. Purely presentational: owns no player logic, no Theater state, and no data fetch. Layout CSS lives in `AudioPlayerBar.razor.css` (`.now-showing` / `.now-showing-cover` / `.now-showing-cover-art` / `.now-showing-cover-placeholder` / `.now-showing-title-link` / `.now-showing-title` / `.now-showing-share`); all surface/text binds `--deepdrft-page-*` theme-aware aliases — no new dark overrides.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
@@ -29,6 +30,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
- `TheaterModeToggle.razor`: Phase 20 Theater-Mode toggle button. Visible only when `Available && (State.LavaEnabled || State.WaveformEnabled)` — no visualizer subsystem active → no theater to enter; `Available` is false when this page's release is not the one currently playing (Phase 20 Wave 2). Disabled until interactive (`!RendererInfo.IsInteractive`), same guard as Play and the lava-lamp trigger. On click: flips `WaveformVisualizerControlState.TheaterMode` and calls `NotifyChanged()`. Shows an on/off `aria-pressed` active state. Glyph: Material `Theaters`. `.dd-accent-icon` container gives the green-accent glyph in both themes with zero new CSS — same treatment as `WaveformVisualizerControlPopover`. Subscribes to `State.Changed` in `OnInitialized` and unsubscribes on `Dispose` to re-render when another observer (e.g. `CoerceTheaterMode()`) flips the state. `[Parameter] Size IconSize` (default `Large`) matches the adjacent lava-lamp trigger. `[Parameter] bool Available` (default `true`) — the page passes its `ShowTheaterToggle` predicate here so the toggle is scoped to the playing release; surfaces with no release-scoping pass the default `true`. Placed **immediately left** of the lava-lamp popover on all three detail pages inside a `.dd-detail-top-actions` cluster.
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
@@ -36,8 +38,9 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `QueueList.razor`: Shared presentational queue-list component (Phase 17 wave 17.1). Renders `Items` as an ordered list with the current track marked; `Editable` flag gates drag-reorder handles (drag handle icon + `MudDropContainer`/`MudDropZone` for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (`Editable && !isCurrent`) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain `<div>` — the read-only state for 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`; the component calls no `IQueueService` method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the `Editable` flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
- `QueueOverlay.razor`: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode. Opened/closed by the Queue toggle button in `PlayerTransportZone` (shown only when `!Fixed && Items.Count > 0`; `QueueMusic` glyph, active state when open).
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active.
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
- `SeoHead.razor`: Purely presentational SEO head emitter (Phase 22). Renders a `<PageTitle>` + `<HeadContent>` block from a single `SeoModel` parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → `noindex`), browse views, and the 404 page.
- `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
@@ -48,13 +51,14 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 1664 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions, **two subsystem on/off toggles** (Phase 15), and one **Theater-Mode flag** (Phase 20): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`), `TheaterMode` (bool, default `false``DefaultTheaterMode`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable; the Theater observers (the three detail pages and `AudioPlayerBar`) subscribe to react to `TheaterMode`. **`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. **`ApplyCapabilityDefault(bool hardwareAccelerated)`**: one-time scoped capability default (guarded by `_capabilityDefaultApplied`; never re-applies on SPA navigation, never overrides an explicit in-session toggle). When `hardwareAccelerated` is false (positive software-renderer match from `hwAccel.ts`'s `UNMASKED_RENDERER_WEBGL` probe, or total WebGL failure) sets `LavaEnabled = false` while leaving `WaveformEnabled` at its default on, then calls `CoerceTheaterMode()` + `NotifyChanged()` once so all observers see the default in a single cycle. Called by the visualizer bridge on first interactive render once JS interop (the HW-accel probe via `detectHardwareAcceleration()` exported from `WaveformVisualizer.ts`) is available; a no-op when HW accel is present. `TheaterMode` is a page-chrome presentation flag; the visualizer bridge ignores it. Scoped DI so state survives SPA nav within a session and resets on fresh page load. **Phase 20 Wave 2 — playing-release predicates** live in `ReleaseDetailBase` / `CutDetailBase` (not in this state holder): `IsThisReleasePlaying` (`PlayerService?.CurrentTrack?.Release?.EntryKey == EntryKey`), `IsContentHidden` (`TheaterMode && IsThisReleasePlaying`), `ShowTheaterToggle` (`(LavaEnabled || WaveformEnabled) && IsThisReleasePlaying`). Both base classes also subscribe to `IStreamingPlayerService.StateChanged` (idempotent, reference-guarded, disposed) so the predicates re-evaluate live when playback moves between releases.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. A share is always a live-page user interaction (never a tab-unload), so it sends via the first-party `IEventPoster` fetch **only** — no `sendBeacon` arm. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `IEventPoster` / `HttpEventPoster`: First-party same-origin event transport (telemetry transport-resilience). `PostAsync(url, json)` POSTs an `application/json` body to the host's own `api/event/*` proxy via a default `IHttpClientFactory` client, best-effort and non-throwing. A first-party fetch is not name-matched by tracking/fingerprinting heuristics the way a `telemetry/beacon` `sendBeacon` module is — this is the transport for normal play closes (end/switch/stop) and every share. One seam so the play sink and share tracker share it and tests capture the wire payload behind a fake.
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper over `window.DeepDrftLifecycle` (served from `js/session/lifecycle.js`). After the transport-resilience split it is the **unload-edge transport only**: it fires the play payload via `sendBeacon` on page tear-down (where an awaited fetch would be cancelled) and wires the page-unload handler. Normal closes go over `IEventPoster`. Named off the former `telemetry/beacon` path so the retained fallback isn't name-matched either.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1; transport-resilience split). Serializes the play classification once and dispatches down the arm the close chose: `EmitPlayAsync` over the first-party `IEventPoster` (normal close: organic end / track-switch / stop, page alive) and `EmitPlayOnUnload` over `BeaconInterop` `sendBeacon` (tab-unload edge). Both arms send byte-identical payloads. `PlayTracker.Close(bool viaUnload)` selects the arm — `OnPageUnload` passes `viaUnload: true`, every other close defaults to fetch. **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/session/anonid.ts` (exposes `window.DeepDrftAnonId`, served from `js/session/anonid.js`; mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: **Two-level deque** orchestrator above the single-slot player. The deque has two entry ends. **PLAY (manual)** enters the FRONT: `PlayTrack(track)` and `PlayRelease(tracks, startIndex)` prepend the played track/release in order, **remove the previously-current track**, make the new front current, start streaming it, and leave whatever sat after the old current intact behind the prepend (a whole release prepends in order in one op). The detail pages (Cut header/row, Session/Mix hero) and `StreamNowButton` route their PLAY through these. **Add-to-queue** enters the BACK: `Enqueue`/`EnqueueRange` append to the end without interrupting the current track (`AddToQueueButton`). `Next`/`Previous` advance or step back, walking `CurrentIndex` and leaving played tracks behind so `Previous` can reach them; `JumpTo(index)` moves the pointer to a queued row and streams it once (the playlist panel's row-jump — it does NOT prepend or stream the intervening rows). **End-of-track:** auto-advance (`TrackEnded`) advances when there is a next track; when the **last** track ends naturally the queue **empties** and goes dormant (bug #2) rather than stranding the finished track. `Clear` empties the queue. **Bug #3 (dormant-seed):** the first `Enqueue`/`EnqueueRange` into a dormant queue while a track is already playing externally (via the attached player, not through the queue) seeds the head with that now-playing track and then appends — yielding `[now-playing, added]` (even when adding the same track). The queue learns the externally-playing track through the existing `Attach(player)` seam (`_player.CurrentTrack`) — no new dependency, no `IServiceProvider`. **Armed-idle state** (prerender-safe release embeds): `Arm(tracks)` replaces the queue at index 0 with no JS interop; `IsArmed` signals armed-but-not-streaming; `Start()` streams the current track and clears `IsArmed`. `AudioPlayerBar` reads `IsArmed` to route the embed's first play gesture through `Start()`. `QueueChanged` fires on all list/position changes; cascaded via `AudioPlayerProvider`. `Move`/`RemoveAt` are interop-free reorder/remove mutations that adjust `CurrentIndex` and never re-stream. `ClearUpcoming()` keeps the current track and drops the up-next. **Bug #4 (reactivity):** `AudioPlayerBar.QueueItems` caches `QueueService.Items` as a `_queueItemsCache` snapshot (the service exposes its backing list by reference); the cache is invalidated and set to `null` in `OnQueueChanged`, so every real mutation hands `QueueList` a new list reference while frequent progress-tick re-renders reuse the cached one without allocating. `QueueList.OnParametersSet` calls `_dropContainer?.Refresh()` so the `MudDropContainer` re-reads the new list and the open panel re-flows immediately. **Bug #1 (label):** the docked `QueueOverlay` panel header reads **"Playlist"** (the current track stays listed). `PlayRelease` materializes `tracks.ToList()` before mutating so it can never alias the service's own `Items` list.
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
@@ -68,6 +72,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
- `SeoModel.cs`: Typed per-page SEO input (Phase 22). Named factories: `ForRelease` (medium-dispatched — Cut → `MusicAlbum`, Session → `MusicAlbum`/`LiveAlbum`, Mix → `MusicRecording`), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Encodes the medium→schema.org mapping in one place. `SeoModel.Robots` overrides the environment-default (see `SeoEnvironment`).
- `SeoJsonLd.cs`: Typed schema.org JSON-LD builders (Phase 22). Types: `MusicGroup` (home/about, with `sameAs: ["https://instagram.com/deepdrft.music"]`), `MusicAlbum`/`LiveAlbum` (cuts/sessions, with ordered `MusicRecording` track list and per-release `byArtist`), `MusicRecording` (mixes, with ISO-8601 `duration`), `CollectionPage` (browse). All serialized output is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- `SeoOptions.cs`: Site-wide SEO config (Phase 22). `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 (both server and WASM `Program.cs`). `BaseUrl` is config, not `window.location` — no `window` at server prerender, and the origin cannot be derived reliably behind the nginx proxy.
- `SeoUrls.cs`: URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (Phase 22).
- `SeoEnvironment.cs`: Scoped `[PersistentState]` bridge for the server environment flag (Phase 22). Seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge pattern. 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`.
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
- `_Imports.razor`: Global using statements and component imports.
@@ -127,6 +136,8 @@ New modules in `DeepDrftPublic/Interop/audio/`:
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
**`SeoEnvironment` follows the same `[PersistentState]` bridge pattern** (Phase 22). It is seeded server-side in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` and bridged to the WASM client. Consumers (`SeoHead`) read `SeoEnvironment.IsProduction` to gate the default robots directive (`index,follow` in Production, `noindex,nofollow` elsewhere). The pattern is identical to `DarkModeSettings` — one server-side seed, one `PersistentComponentState` round-trip, one scoped client read.
## MVVM convention
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
@@ -140,6 +151,22 @@ Component state lives in ViewModels (registered scoped in DI). Components render
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
### Interactive-accent icons (`.dd-accent-icon` / `.dd-accent-fill`)
Green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger, etc.) use a **single reusable treatment** in `deepdrft-styles.css`, not per-site dark overrides. Wrap the affordance(s) in a container carrying `.dd-accent-icon`; the rule colours the inner `.mud-icon-root` glyph green-accent (`--deepdrft-green-accent`, the brand constant — same value in both palettes) in **both** themes. Add `.dd-accent-fill` to the same container when it also holds a filled `Color.Secondary` `MudButton` whose fill must go green-accent in **dark** (dark-only — light already renders green fill + white text).
Two reasons this is needed and why it's a class, not a palette colour: (1) no MudBlazor `Color` enum is green in both themes (`Dark.Secondary` is off-white), so palette-only solutions can't express "green in both"; (2) MudBlazor stamps the standalone rule `.mud-secondary-text { color: …secondary !important }` (0,1,0) on the glyph `<svg>`, so wrapper-level overrides never reach it — the reusable rule targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important`, which beats it on specificity alone; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too: they were already green-accent in light (via `Color.Secondary``Light.Secondary`), so folding them in keeps light pixel-identical while fixing the dark over-image glyphs — they are not actually theme-divergent. **Add new green-accent icon affordances by applying this class, not by spawning a new dark override.**
**Self-themed components are authoritative over `.dd-accent-icon`.** `PlayStateIcon` owns its glyph colour inside `.icon-container` and must beat a surrounding `.dd-accent-icon` in dark — its scoped CSS rule targets `.mud-icon-root` at (0,5,0) `!important` (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) `!important`. Do not wrap a `PlayStateIcon` in `.dd-accent-icon` expecting to recolor its play-chip glyph — the play chip always shows navy (`--deepdrft-play-glyph`) against the moss-green chip in dark.
**Layout-only cluster class: `.dd-detail-top-actions`.** When two or more icon affordances sit together in a top-action row (e.g. the Theater toggle + lava-lamp popover on the three detail pages), wrap them in `.dd-detail-top-actions` — a layout-only `display:flex; align-items:center; gap:0.25rem` class in `deepdrft-styles.css`. No colour; prevents the `SpaceBetween` row from spreading the icons apart. Each affordance inside still carries its own `.dd-accent-icon` wrapper independently.
**Full-screen detail body: `.dd-detail-fill`.** Phase 20 Wave 2. Applied to each detail page's foreground content container (the `<div>` or `<MudContainer>` that wraps the scaffold/hero); sets `min-height: calc(100vh - var(--deepdrft-nav-height, 88px))` so the ambient/full-bleed visualizer reads as genuinely full-screen and the site footer is pushed below the fold, independent of Theater Mode. Reuses `--deepdrft-nav-height` (88px desktop / 72px mobile) so the clearance tracks the nav bar height across breakpoints; no new layout token. Defined in `deepdrft-styles.css`.
**Eased Theater Mode collapse: `.dd-theater-collapsible` / `.dd-theater-collapsed`.** Phase 20 Wave 2. Used wherever Theater Mode should ease content in/out rather than pop via `@if`. The outer wrapper carries `.dd-theater-collapsible` (always present); its single direct child carries `.dd-theater-collapsible-inner`; adding `.dd-theater-collapsed` to the outer collapses the region. Technique: `grid-template-rows: 1fr → 0fr` (real-height interpolation), `opacity`, and `visibility: hidden` + `transition-behavior: allow-discrete` (visibility flip deferred to end of ease-out so collapsed content is removed from the tab order once the animation completes; immediately re-shown on expand). A `prefers-reduced-motion` block collapses instantly. Used on the release content regions in all three detail pages (`IsContentHidden` predicate) and on the player-bar `NowShowingPanel` band (collapsed when `!TheaterMode`). Defined in `deepdrft-styles.css`.
**Gas-lamp toggle is self-colored in its SVG.** `DDIcons.GasLampLit` (dark-mode icon) carries `fill="#2A5C4F"` directly on its frame path — no CSS colour override is needed. The former dark nav rule (`.deepdrft-theme-dark .dd-nav-actions .mud-icon-button`) has been removed as dead. `DDIcons.GasLamp` (light-mode icon) continues to use `currentColor` and inherits nav text colour in light (the unlit toggle is theme-divergent by design).
## Development commands
```bash
@@ -2,6 +2,8 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
@@ -18,13 +20,24 @@ public class TrackMediaResponse : IDisposable
/// </summary>
public string ContentType { get; }
/// <summary>
/// The total file length in bytes, parsed from the 206 response's <c>Content-Range:
/// bytes start-end/TOTAL</c> header (Phase 21 Direction B). Null when the server returned
/// 200 (no Content-Range) — callers fall back to <see cref="ContentLength"/> as the total.
/// This is the EOF boundary the segment loop advances its cursor toward, and the full
/// logical length the JS decoder must see (so a bounded segment's small Content-Length
/// never trips the decoder's byte-count completion early).
/// </summary>
public long? TotalLength { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
public TrackMediaResponse(Stream stream, long contentLength, string contentType, long? totalLength, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
ContentType = contentType;
TotalLength = totalLength;
_response = response;
}
@@ -45,24 +58,61 @@ public class TrackMediaClient
}
/// <summary>
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
/// Fetches the audio stream for a track via an HTTP Range request starting at a
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
/// the start of the file on disk (including the WAV header) — callers seeking into
/// audio data must add the header size themselves. The cancellation token aborts
/// the in-flight server connection rather than leaving the server draining bytes
/// into a dead socket.
/// the start of the file on disk (including any container/header bytes) — callers
/// seeking into audio data must add the header size themselves. The cancellation
/// token aborts the in-flight server connection rather than leaving the server
/// draining bytes into a dead socket.
/// <para>
/// <paramref name="byteEnd"/> (Phase 21 Direction B) bounds the request to a single
/// segment: when set, the Range header is <c>bytes={byteOffset}-{byteEnd}</c> (inclusive),
/// so the browser holds at most ~one segment of raw bytes regardless of file size — the
/// network-memory bound this phase exists for. When null the request is open-ended
/// (<c>bytes={byteOffset}-</c>), the pre-Direction-B behaviour. Either way the response's
/// <c>Content-Range</c> total is surfaced via <see cref="TrackMediaResponse.TotalLength"/>
/// so the caller knows the EOF boundary and the full logical length the decoder must see.
/// </para>
/// <para>
/// <paramref name="format"/> selects the delivery rendering (Phase 18): the default
/// <see cref="AudioFormat.Lossless"/> sends no <c>format</c> query param, so existing
/// callers hit the byte-identical pre-Phase-18 endpoint; <see cref="AudioFormat.Opus"/>
/// requests the low-data Ogg Opus artifact, which the server resolves and falls back to
/// lossless when absent (C2). The response <see cref="TrackMediaResponse.ContentType"/>
/// reports the format actually served, so the JS decoder dispatches on the real bytes.
/// </para>
/// </summary>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
long byteOffset = 0,
long? byteEnd = null,
AudioFormat format = AudioFormat.Lossless,
CancellationToken cancellationToken = default)
{
try
{
// Same URL for every seek — only the Range header differs. byteOffset 0 is
// Same URL for every fetch — only the Range header differs. byteOffset 0 is
// not special-cased: "bytes=0-" requests the whole file from the start.
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
// Lossless omits the format param entirely so the request is byte-identical to
// the pre-Phase-18 endpoint; only Opus appends ?format=opus.
var uri = format == AudioFormat.Lossless
? $"api/track/{trackId}"
: $"api/track/{trackId}?format={format.ToString().ToLowerInvariant()}";
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
// Bounded (byteEnd set) → "bytes=start-end" so the server returns a finite 206
// slice and the browser buffers only that segment; open-ended (byteEnd null) →
// "bytes=start-". The server honours both via File(..., enableRangeProcessing: true),
// which parses the full RFC 7233 range grammar and slices accordingly.
request.Headers.Range = new RangeHeaderValue(byteOffset, byteEnd);
// Stream the response body incrementally instead of buffering it whole (Phase 21.4 fix).
// In Blazor WebAssembly the HttpClient is backed by the browser fetch API; without this the
// browser buffers the ENTIRE body before the response stream yields a byte. With Direction B
// each request is already bounded to one segment, so the body is small regardless — but
// streaming still lets us read it incrementally and is harmless on the SSR server-to-server
// path (SocketsHttpHandler ignores the unknown option). Kept for both the initial and the
// seek/refill paths since both share this method.
request.SetBrowserResponseStreamingEnabled(true);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -72,11 +122,15 @@ public class TrackMediaClient
// Default to WAV when the server omits the header — the only format shipping
// today — so the JS factory always receives a usable media type.
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
// Content-Range "bytes start-end/TOTAL" carries the full file length on a 206; on a 200
// there is no Content-Range, so TotalLength is null and callers use ContentLength.
var totalLength = response.Content.Headers.ContentRange?.Length;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
// TrackMediaResponse takes ownership of both stream and response;
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
return ApiResult<TrackMediaResponse>.CreatePassResult(
new TrackMediaResponse(stream, contentLength, contentType, totalLength, response));
}
catch (Exception e)
{
@@ -115,4 +169,33 @@ public class TrackMediaClient
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
}
}
/// <summary>
/// Fetches a track's Opus seek/setup sidecar — the combined OpusHead/OpusTags setup header plus the
/// granule→byte seek index (Phase 18). The caller (18.5 player wiring) fetches this once on track load
/// and parses it into the JS-side OpusSeekData before issuing any Opus seek. A 404 means no Opus
/// artifact / sidecar exists for the track (legacy row, not backfilled, or transcode failed); callers
/// treat that as "this track has no Opus seek data — stay on lossless" rather than an error, so it
/// surfaces as a fail result with a stable message rather than throwing (mirrors GetWaveformProfileAsync).
/// </summary>
public async Task<ApiResult<byte[]>> GetOpusSidecarAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/opus/seekdata", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult<byte[]>.CreateFailResult("No Opus sidecar available");
}
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return ApiResult<byte[]>.CreatePassResult(bytes);
}
catch (Exception e)
{
return ApiResult<byte[]>.CreateFailResult(e.Message);
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The single public-site listener-settings object (Phase 18 wave 18.6, §4a). The generalized analogue of
/// <see cref="DarkModeSettings"/>: one scoped holder for every remembered listener preference, seeded at
/// server prerender, carried into WASM via <see cref="PersistentState"/>, and persisted to a cookie on
/// change. Today it carries one preference — streaming quality; tomorrow dark mode (and whatever follows)
/// folds in here as another property without disturbing the menu that reads it.
/// <para>
/// Built design-for-adaptability per §4a: a new preference is a new <c>[PersistentState]</c> property here
/// plus a new <see cref="Components.SettingsItem"/> in the menu — not a rewire. Dark mode is intentionally
/// <em>not</em> migrated in now (it keeps its own <see cref="DarkModeSettings"/> seam); this object is shaped
/// so that consolidation is later a merge of two identical seams, not a reconciliation of two different ones.
/// </para>
/// </summary>
public class PublicSiteSettings
{
/// <summary>
/// The listener's streaming-quality preference. Defaults to <see cref="StreamQuality.LowData"/> (Opus,
/// capability-gated — OQ2). Seeded from the <c>streamQuality</c> cookie at prerender; persisted on change
/// by the client cookie service. The player reads this to decide which <c>?format=</c> to request, but
/// the capability gate and C2 fallback still apply on top, so a <see cref="StreamQuality.LowData"/>
/// preference never forces an unplayable stream.
/// </summary>
[PersistentState]
public StreamQuality StreamQuality { get; set; } = StreamQuality.LowData;
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
/// not be crawled, so the <i>default</i> robots directive is environment-gated: <c>index,follow</c> only in
/// Production, <c>noindex,nofollow</c> everywhere else. A per-page <see cref="SeoModel.Robots"/> override
/// still wins — this only sets the default.
///
/// <para>
/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
/// the same flag. The WASM assembly has no <c>IWebHostEnvironment</c> (config comes from the server). This
/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
/// <c>IWebHostEnvironment.IsProduction()</c>) and <c>[PersistentState]</c> rounds to the client, so both
/// passes resolve the identical value. <c>SeoHead</c> injects this rather than an environment dependency,
/// honouring the no-environment-in-the-component constraint.
/// </para>
/// </summary>
public class SeoEnvironment
{
/// <summary>
/// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
/// <c>false</c> so the fail-safe is "do not index" — a missing bridge never accidentally opens a
/// non-production site to crawlers.
/// </summary>
[PersistentState]
public bool IsProduction { get; set; }
/// <summary>The environment-gated default robots directive. Explicit page values override this.</summary>
public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
}
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
/// schema.org type; <see cref="SeoJsonLd.Serialize"/> renders a node to the <c>&lt;script type="application/ld+json"&gt;</c>
/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping
/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass.
///
/// <para>
/// All nodes share <see cref="JsonLdNode"/> so the <c>@context</c>/<c>@type</c> pair serialises first and
/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty
/// or broken node (C6/AC4).
/// </para>
/// </summary>
public static class SeoJsonLd
{
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
// each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
// Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
// body is injected into the <script> element. See Serialize.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false,
};
/// <summary>
/// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
/// The body is run through <see cref="InlineSafe"/> so CMS-authored values containing
/// <c>&lt;/script&gt;</c> or <c>&lt;</c> cannot break out of the inline script element (XSS).
/// </summary>
public static string Serialize<TNode>(TNode node) where TNode : JsonLdNode =>
InlineSafe(JsonSerializer.Serialize(node, node.GetType(), Options));
/// <summary>
/// Escapes the three characters that can break out of an inline <c>&lt;script type="application/ld+json"&gt;</c>
/// element. Replacing <c>&lt;</c>/<c>&gt;</c>/<c>&amp;</c> with their <c>\uXXXX</c> JSON escapes keeps the
/// JSON byte-for-byte equivalent on parse (a JSON string treats <c><</c> and <c>&lt;</c> identically)
/// while making <c>&lt;/script&gt;</c> impossible to emit raw — the documented safe pattern for inline JSON-LD.
/// </summary>
internal static string InlineSafe(string json) => json
.Replace("<", "\\u003C")
.Replace(">", "\\u003E")
.Replace("&", "\\u0026");
}
/// <summary>Base for every schema.org node: emits <c>@context</c> and <c>@type</c> first.</summary>
public abstract record JsonLdNode
{
[JsonPropertyName("@context")]
[JsonPropertyOrder(-2)]
public string Context => "https://schema.org";
[JsonPropertyName("@type")]
[JsonPropertyOrder(-1)]
public abstract string Type { get; }
}
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
public sealed record MusicGroupNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")] public string? Url { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("logo")] public string? Logo { get; init; }
[JsonPropertyName("sameAs")] public IReadOnlyList<string>? SameAs { get; init; }
}
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
public sealed record MusicAlbumNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicAlbum";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>schema.org <c>MusicAlbumProductionType</c> URI, e.g. <c>StudioAlbum</c> or <c>LiveAlbum</c>.</summary>
[JsonPropertyName("albumProductionType")] public string? AlbumProductionType { get; init; }
[JsonPropertyName("datePublished")] public string? DatePublished { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
/// <summary>Ordered list of the album's recordings (cut track list, in TrackNumber order).</summary>
[JsonPropertyName("track")] public IReadOnlyList<MusicRecordingNode>? Track { get; init; }
}
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
public sealed record MusicRecordingNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicRecording";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from <c>DurationSeconds</c>.</summary>
[JsonPropertyName("duration")] public string? Duration { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
public sealed record CollectionPageNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "CollectionPage";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A nested <c>byArtist</c> reference — the collective as a MusicGroup, by name.</summary>
public sealed record ArtistRef
{
[JsonPropertyName("@type")] public string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
}
+209
View File
@@ -0,0 +1,209 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The OG <c>og:type</c> for a page. Releases map per medium (§3.4); everything else is a website.
/// </summary>
public enum SeoOgType
{
Website,
MusicAlbum,
MusicSong,
}
/// <summary>
/// The typed per-page SEO input (Phase 22). A page hands <c>SeoHead</c> one model instead of ~15 loose
/// parameters; the named factories below encode the per-page / per-medium mapping (title, description,
/// canonical path, og:type, JSON-LD node) in exactly one place each (DRY, §4.1/§4.2). The factories are
/// pure functions over DTOs the page already holds — unit-testable without rendering.
///
/// <para>
/// <see cref="CanonicalPath"/> is site-relative; <c>SeoHead</c> absolutises it against
/// <see cref="SeoOptions.BaseUrl"/>. Release pages pass <see cref="ReleaseRoutes.DetailHref"/> so the
/// canonical is the dedicated route regardless of alias/query routes (AC7). A null cover means the model
/// carries no <see cref="ImagePath"/> and <c>SeoHead</c> falls back to the default OG image (C6/AC4).
/// </para>
/// </summary>
public sealed record SeoModel
{
/// <summary>Bare page title, no site suffix. <c>SeoHead</c> composes <c>"{Title} · {suffix}"</c>.</summary>
public required string Title { get; init; }
/// <summary>Meta/OG description. Null falls back to <see cref="SeoOptions.DefaultDescription"/>.</summary>
public string? Description { get; init; }
/// <summary>Site-relative canonical path. Null defaults to the current path in <c>SeoHead</c>.</summary>
public string? CanonicalPath { get; init; }
/// <summary>Relative cover <c>ImagePath</c>. Null → the default OG image.</summary>
public string? ImagePath { get; init; }
public SeoOgType OgType { get; init; } = SeoOgType.Website;
/// <summary>Robots directive. Null falls back to <see cref="SeoOptions.DefaultRobots"/>.</summary>
public string? Robots { get; init; }
/// <summary>Pre-serialised JSON-LD script body, or null to emit no structured-data script.</summary>
public string? JsonLd { get; init; }
// --- Music-vertical OG, release pages only (null elsewhere → tags omitted) ---
public string? Artist { get; init; }
public DateOnly? ReleaseDate { get; init; }
public double? DurationSeconds { get; init; }
// ------------------------------------------------------------------ Factories
/// <summary>Home page: the collective entity (MusicGroup JSON-LD), site-level OG.</summary>
public static SeoModel ForHome(SeoOptions options) => new()
{
Title = "Electronic Music Collective",
Description = options.DefaultDescription,
CanonicalPath = "/",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options)),
};
/// <summary>About page: the collective again, with the bio lede as description.</summary>
public static SeoModel ForAbout(SeoOptions options) => new()
{
Title = "The Collective",
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
CanonicalPath = "/about",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options) with
{
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
}),
};
/// <summary>A browse surface: <c>CollectionPage</c> JSON-LD, website OG.</summary>
public static SeoModel ForBrowse(SeoOptions options, ReleaseMedium? medium, string path)
{
var (title, description) = BrowseCopy(medium);
return new SeoModel
{
Title = title,
Description = description,
CanonicalPath = path,
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(new CollectionPageNode
{
Name = title,
Description = description,
Url = SeoUrls.Absolute(options, path),
}),
};
}
/// <summary>The 404 page: no canonical, <c>noindex,follow</c>, no JSON-LD.</summary>
public static SeoModel ForNotFound(SeoOptions options) => new()
{
Title = "Not Found",
Description = options.DefaultDescription,
Robots = "noindex,follow",
OgType = SeoOgType.Website,
};
/// <summary>
/// A release detail page. The medium picks the schema (cut/session → MusicAlbum, mix → MusicRecording),
/// the og:type, and the music-vertical OG fields; the canonical is the dedicated route. The optional
/// <paramref name="tracks"/> seed the album's ordered <c>track</c> list (cut). <b>One call site, all tags.</b>
/// </summary>
public static SeoModel ForRelease(SeoOptions options, ReleaseDto release, IReadOnlyList<TrackDto>? tracks = null)
{
var canonicalPath = ReleaseRoutes.DetailHref(release.EntryKey, release.Medium);
var image = SeoUrls.CoverOrDefault(options, release.ImagePath);
// byArtist reflects the release's own artist, consistent with the music:musician OG tag (Daniel's
// call) — not the collective name. Album sub-recordings share it: the tracks are by this artist.
var artist = new ArtistRef { Name = release.Artist };
var description = string.IsNullOrWhiteSpace(release.Description) ? options.DefaultDescription : release.Description;
// A mix is a single recording; its duration comes from the (single) track when present.
var mixDurationSeconds = release.Medium == ReleaseMedium.Mix
? tracks?.FirstOrDefault()?.DurationSeconds
: null;
JsonLdNode node = release.Medium switch
{
ReleaseMedium.Mix => new MusicRecordingNode
{
Name = release.Title,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(mixDurationSeconds),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
},
// Cut and Session are both albums; the production type distinguishes a live session.
_ => new MusicAlbumNode
{
Name = release.Title,
ByArtist = artist,
AlbumProductionType = release.Medium == ReleaseMedium.Session
? "https://schema.org/LiveAlbum"
: "https://schema.org/StudioAlbum",
DatePublished = release.ReleaseDate?.ToString("yyyy-MM-dd"),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
Track = AlbumTracks(options, artist, tracks),
},
};
return new SeoModel
{
Title = release.Title,
Description = description,
CanonicalPath = canonicalPath,
ImagePath = release.ImagePath,
OgType = release.Medium == ReleaseMedium.Mix ? SeoOgType.MusicSong : SeoOgType.MusicAlbum,
Artist = release.Artist,
ReleaseDate = release.ReleaseDate,
DurationSeconds = mixDurationSeconds,
JsonLd = SeoJsonLd.Serialize(node),
};
}
// The collective entity, built once from config — the home/about JSON-LD root.
private static MusicGroupNode MusicGroup(SeoOptions options) => new()
{
Name = options.SiteName,
Url = SeoUrls.Absolute(options, "/"),
Genre = options.Genre,
Description = options.DefaultDescription,
Logo = SeoUrls.Absolute(options, options.DefaultImageUrl),
SameAs = options.SameAs.Count > 0 ? options.SameAs : null,
};
// Ordered recording list for an album's `track` property. Null when there are no tracks so the
// property is omitted rather than emitting an empty array (C6).
private static IReadOnlyList<MusicRecordingNode>? AlbumTracks(
SeoOptions options, ArtistRef artist, IReadOnlyList<TrackDto>? tracks)
{
if (tracks is null || tracks.Count == 0) return null;
return tracks
.OrderBy(t => t.TrackNumber)
.Select(t => new MusicRecordingNode
{
Name = t.TrackName,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(t.DurationSeconds),
})
.ToList();
}
private static (string Title, string Description) BrowseCopy(ReleaseMedium? medium) => medium switch
{
ReleaseMedium.Cut => ("Cuts", "Studio cuts from Deep DRFT — composed, layered, and finished."),
ReleaseMedium.Session => ("Sessions", "Live sessions from Deep DRFT — performances caught in the moment, unrepeatable and unedited."),
ReleaseMedium.Mix => ("Mixes", "DJ mixes from Deep DRFT — uninterrupted sets, one track bleeding into the next."),
_ => ("Archive", "The full Deep DRFT catalogue — cuts, sessions, and mixes, indexed and always expanding."),
};
}
@@ -0,0 +1,52 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Site-wide SEO defaults (Phase 22). These are non-secret brand constants — a single canonical origin,
/// the site name/suffix, the fallback share image, the social links — sourced once and injected into
/// <c>SeoHead</c> so no page re-declares them. Registered as a singleton in
/// <see cref="Startup.ConfigureDomainServices"/>, which runs in <b>both</b> the server prerender and the
/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
///
/// <para>
/// <see cref="BaseUrl"/> is the load-bearing field: absolute canonical / <c>og:url</c> / <c>og:image</c>
/// origins all come from here, never from a browser API — there is no <c>window.location</c> during
/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
/// </para>
/// </summary>
public sealed record SeoOptions
{
/// <summary>Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).</summary>
public string BaseUrl { get; init; } = "https://deepdrft.com";
/// <summary>The brand name used in <c>og:site_name</c>, <c>application-name</c>, and the JSON-LD MusicGroup.</summary>
public string SiteName { get; init; } = "Deep DRFT";
/// <summary>Appended to a page's bare title as <c>"{Title} · {TitleSuffix}"</c>. Resolves the prior suffix inconsistency (OQ4).</summary>
public string TitleSuffix { get; init; } = "Deep DRFT";
/// <summary>Fallback meta/OG description for pages that supply none.</summary>
public string DefaultDescription { get; init; } =
"Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
/// <summary>
/// Absolute or root-relative URL of the default 1200×630 share image used when a page has no cover (OQ2).
/// A placeholder path until the real asset is dropped in; swapping it is a one-value change.
/// </summary>
public string DefaultImageUrl { get; init; } = "/img/og-default.png";
/// <summary>OG locale. Optional surface tag.</summary>
public string Locale { get; init; } = "en_US";
/// <summary>The collective's primary genre, used in the MusicGroup JSON-LD node.</summary>
public string Genre { get; init; } = "Electronic";
// The default robots directive is NOT a static option — it is environment-gated (Production →
// index,follow; non-production → noindex,nofollow) via SeoEnvironment so the beta/staging site is
// never crawled. A page's explicit SeoModel.Robots still overrides that default.
/// <summary>
/// Public social profile URLs for the MusicGroup <c>sameAs</c> array (OQ3). Instagram only —
/// no Twitter/X account exists, so no <c>twitter:site</c>/<c>twitter:creator</c> handle is emitted.
/// </summary>
public IReadOnlyList<string> SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
}
+44
View File
@@ -0,0 +1,44 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Absolute-URL composition for SEO tags (Phase 22). Canonical / <c>og:url</c> / <c>og:image</c> origins
/// all come from <see cref="SeoOptions.BaseUrl"/> (config), never from a browser API — there is no
/// <c>window.location</c> during server prerender and the request host is unreliable behind nginx
/// (§5, OQ1). Shared by the <c>SeoModel</c> factories (which absolutise JSON-LD <c>url</c>/<c>image</c>)
/// and <c>SeoHead</c> (which absolutises the meta/OG tags) so the rule lives in exactly one place.
/// </summary>
public static class SeoUrls
{
/// <summary>BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.</summary>
public static string Absolute(SeoOptions options, string path)
{
var origin = options.BaseUrl.TrimEnd('/');
if (string.IsNullOrEmpty(path)) return origin;
return $"{origin}/{path.TrimStart('/')}";
}
/// <summary>
/// Absolute URL of a release/track cover from its FileDatabase <c>ImagePath</c>, via the public image
/// route (<c>api/image/{escaped}</c>). Returns the configured default share image when no cover exists
/// (C6/AC4 — a default guarantees <c>og:image</c> presence).
/// </summary>
public static string CoverOrDefault(SeoOptions options, string? imagePath)
{
if (string.IsNullOrWhiteSpace(imagePath))
return Absolute(options, options.DefaultImageUrl);
return Absolute(options, $"api/image/{Uri.EscapeDataString(imagePath)}");
}
/// <summary>
/// ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from a seconds value, for JSON-LD <c>duration</c> and the
/// <c>music:duration</c> OG tag. Null / non-finite / non-positive input yields null (omit the tag).
/// </summary>
public static string? IsoDuration(double? seconds)
{
if (seconds is null || double.IsNaN(seconds.Value) || double.IsInfinity(seconds.Value) || seconds.Value <= 0)
return null;
return System.Xml.XmlConvert.ToString(TimeSpan.FromSeconds(seconds.Value));
}
}
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// One entry in the public-site Settings menu (Phase 18 wave 18.6, §4a). The settings-item abstraction the
/// menu renders instead of a hard-coded control list: a <see cref="Label"/> plus a <see cref="Control"/>
/// fragment bound to a persisted preference. Adding a future tenant (e.g. dark mode) is appending one of
/// these — not rewiring the menu. The control fragment owns its own binding to <see cref="PublicSiteSettings"/>
/// and its own persistence call, so each item is self-contained and the menu stays preference-agnostic.
/// </summary>
public sealed record SettingsItem(string Label, RenderFragment Control);
@@ -0,0 +1,18 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The listener's streaming-quality preference (Phase 18 wave 18.6, §4). This is the user's <em>intent</em>,
/// not the wire format that ultimately gets served: <see cref="LowData"/> means "give me Opus if you can,"
/// but the player still capability-gates and C2-falls-back to lossless when Opus can't play (a browser that
/// can't decode Ogg Opus, or a track with no Opus artifact). It is therefore deliberately distinct from
/// <c>DeepDrftModels.Enums.AudioFormat</c> (the delivery rendering resolved per request): one is the
/// remembered preference, the other is what a given stream request actually asks for.
/// </summary>
public enum StreamQuality
{
/// <summary>Bandwidth-friendly Opus (capability-gated; the default before any choice — OQ2).</summary>
LowData,
/// <summary>The lossless WAV path, always playable everywhere.</summary>
Lossless
}
@@ -3,10 +3,11 @@
@using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
cascaded IQueueService's Enqueue/EnqueueRange (which append to the END without disturbing current
playback; a first add into a dormant queue seeds the head from the externally-playing track when one
exists, then appends) — never PlayRelease/PlayTrack/Start/Select. Track mode (Track set) appends a
single track; release mode (ReleaseTracks set) appends the whole ordered list. Reads queue state from
the layout-level cascade (C1); owns no data fetch. *@
<MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
@@ -10,6 +10,22 @@ else
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<MudPaper Elevation="8" Class="player-surface pa-3">
@* Theater Mode "now showing" band (Phase 20 §5/§7, Wave 2 §2). Keyed off the playing
track's Release, not off any detail page (the bar reaches into no page; §6). The release
page is hidden in Theater Mode, so the bar carries its identity: cover, linked title,
release share. The band stays mounted whenever a release is playing and eases in/out via
the shared .dd-theater-collapsible wrapper — collapsed (zero height, faded) unless
Theater is ON — so the bar grows/shrinks smoothly instead of popping. *@
@if (CurrentTrack?.Release is not null)
{
var nowShowing = VisualizerControlState.TheaterMode;
<div class="dd-theater-collapsible @(nowShowing ? null : "dd-theater-collapsed")">
<div class="dd-theater-collapsible-inner">
<NowShowingPanel Release="CurrentTrack.Release" />
</div>
</div>
}
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
@@ -45,7 +61,7 @@ else
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
overlay, which calls JumpTo (moves the pointer and streams the row, clearing IsArmed).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@@ -16,12 +16,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
// Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
// TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
// the detail pages; the bar only observes — single source, multiple observers (§6).
[Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue;
private bool _subscribedToVisualizerState;
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
// spacer reserves its space. We mirror this element's live height into a CSS
@@ -30,10 +36,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// error banner.
//
// _miniDock is the minimized FAB container. We observe it in minimized state so
// --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer
// clips to the top of the FAB rather than extending to the viewport bottom (fix §1).
// The player-spacer's .minimized class uses a hardcoded 60px and ignores the var,
// so publishing the FAB height here does not regress the spacer.
// --player-height stays non-zero (the FAB's actual height). The player-spacer's
// .minimized class uses a hardcoded 60px and ignores the var, so this is belt-and-
// braces; the var's sole live consumer is the spacer's .expanded height.
private ElementReference _playerRoot;
private ElementReference _miniDock;
private ElementReference _lastObservedElement;
@@ -85,7 +90,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
// Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
// backing list by reference, so passing it straight through means Blazor parameter diffing sees
// an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
// MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
// rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
// progress-tick re-renders (the frequent path) reuse the cached one without allocating.
private IReadOnlyList<TrackDto>? _queueItemsCache;
private IReadOnlyList<TrackDto> QueueItems =>
_queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
@@ -135,12 +148,28 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
QueueService.QueueChanged += OnQueueChanged;
_subscribedQueue = QueueService;
}
// Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
// appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
// so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
if (!_subscribedToVisualizerState)
{
VisualizerControlState.Changed += OnVisualizerStateChanged;
_subscribedToVisualizerState = true;
}
}
private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged()
{
// Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
// This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
// while progress-tick re-renders that don't go through here keep the cached reference.
_queueItemsCache = null;
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
// mount too, so this keeps state and view consistent.
@@ -189,12 +218,14 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void ClearUpcoming() => QueueService?.ClearUpcoming();
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
// touches playback — it streams the chosen track via the player.
// Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
// not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
// moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
// queue action besides PLAY/skip that touches playback.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
await QueueService.JumpTo(index);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -215,13 +246,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
// For the docked player: we observe in BOTH expanded and minimized states
// so --player-height always reflects the live height of whichever element
// is visible. This keeps the WaveformVisualizer clipped to the top of
// the footer in both states (fix §1).
// so --player-height stays non-zero and always reflects the live height of
// whichever element is visible. The var's sole live consumer is the
// player-spacer's .expanded height (keeps the spacer sized correctly across
// breakpoints and banner reflows).
// expanded → observe _playerRoot (full player bar, reflows across breakpoints)
// minimized → observe _miniDock (floating FAB container, ~5660px)
// The player-spacer's .minimized class uses a hardcoded height and ignores
// the var, so publishing the FAB height here does not regress the spacer.
// The player-spacer's .minimized class uses a hardcoded 60px and ignores
// the var, so observing in minimized state is belt-and-braces; it does not
// regress the spacer.
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
@@ -387,6 +420,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedQueue = null;
}
if (_subscribedToVisualizerState)
{
VisualizerControlState.Changed -= OnVisualizerStateChanged;
_subscribedToVisualizerState = false;
}
if (_spacerModule is not null)
{
try
@@ -42,6 +42,68 @@
right: 0.5rem;
}
/* PLAYER-BAR play-chip override (Phase 18, T3). PlayStateIcon's chip defaults to the solid
--deepdrft-play-chip (moss-green in dark) used on release heroes and Cut track rows. On the
player dock that solid green reads too hot, so here and only here swap to the
translucent --deepdrft-play-chip-soft (same green, much less opaque).
The glyph stays --mud-palette-primary (green on the soft translucent wash), giving the
preferred green-on-green look on the player bar in dark mode. */
::deep .player-surface .icon-container {
background-color: var(--deepdrft-play-chip-soft);
}
::deep .player-surface .icon-container .mud-icon-button {
color: var(--mud-palette-primary);
}
/* Theater Mode "now showing" band (Phase 20 §5/§7). Sits above the transport layout inside the
player surface and lets the bar grow taller to carry the hidden release's identity. The band only
renders when Theater is ON, so this geometry is gated by render-inclusion, not a CSS flag when
Theater is OFF the player bar is byte-for-byte its non-Theater self.
Colour/surface come from the bar's themed --deepdrft-page-* aliases; no new token, no dark override. */
::deep .now-showing {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--deepdrft-page-text-muted);
min-width: 0;
}
/* Fixed cover box the reused .deepdrft-track-detail-cover-art / -placeholder idioms are height:100%,
so the band supplies the square frame they fill. */
::deep .now-showing-cover {
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 6px;
overflow: hidden;
}
::deep .now-showing-cover-art,
::deep .now-showing-cover-placeholder {
width: 100%;
}
::deep .now-showing-cover-placeholder .mud-icon-root {
font-size: 24px;
}
::deep .now-showing-title-link {
flex: 1 1 auto;
min-width: 0;
text-decoration: none;
}
::deep .now-showing-title {
color: var(--deepdrft-page-text);
}
::deep .now-showing-share {
flex: 0 0 auto;
}
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
@@ -0,0 +1,46 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls
@* "Now showing" block surfaced in the player bar when Theater Mode is ON (Phase 20 §5/§7). Theater
hides the release page, so the bar carries the release identity the page would have shown: cover art,
the release title linked to its detail page, and a release-mode share. Purely presentational — it owns
no player logic and no Theater state; AudioPlayerBar mounts it only when state.TheaterMode &&
CurrentTrack?.Release is not null, so Release is non-null here.
Theming is all reuse (§8, zero new CSS): the cover reuses the deepdrft-track-detail-cover-art /
-placeholder idiom; the share glyph goes green-accent in both themes via .dd-accent-icon; surface and
text come from the bar's own .player-surface and the .now-showing-* classes in the global sheet, which
bind the theme-aware --deepdrft-page-* aliases. *@
<div class="now-showing">
<div class="now-showing-cover">
@if (!string.IsNullOrEmpty(Release.ImagePath))
{
<div class="deepdrft-track-detail-cover-art now-showing-cover-art"
style="@($"background-image: url('api/image/{Uri.EscapeDataString(Release.ImagePath)}');")"></div>
}
else
{
<div class="deepdrft-track-detail-cover-placeholder deepdrft-gradient-soft-secondary now-showing-cover-placeholder">
<MudIcon Icon="@Icons.Material.Filled.Album" Color="Color.Primary" />
</div>
}
</div>
<a href="@ReleaseRoutes.DetailHref(Release)" class="now-showing-title-link">
<MudText Typo="Typo.subtitle2" Class="now-showing-title text-truncate">
@Release.Title
</MudText>
</a>
<div class="dd-accent-icon now-showing-share">
<SharePopover ReleaseEntryKey="@Release.EntryKey" ReleaseMedium="@Release.Medium" />
</div>
</div>
@code {
/// <summary>The current playing track's release. Non-null by the bar's mount gate.</summary>
[Parameter, EditorRequired] public ReleaseDto Release { get; set; } = default!;
}
@@ -14,14 +14,6 @@
Color="Color.Primary"
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed || HasPrevious || HasNext)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
}
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop"
@@ -30,4 +22,12 @@
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
@if (!Fixed || HasPrevious || HasNext)
{
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary"
Size="Size.Large"
OnClick="@SkipNext"
Disabled="!HasNext"/>
}
</MudStack>
@@ -23,17 +23,19 @@
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
Color="Color.Primary"
Size="Size.Medium"
OnClick="QueueToggle"
aria-label="Queue"
aria-expanded="@QueueOpen"
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
<MudStack Row AlignItems="AlignItems.Center">
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
Color="Color.Primary"
Size="Size.Medium"
OnClick="QueueToggle"
aria-label="Queue"
aria-expanded="@QueueOpen"
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
</MudStack>
</MudStack>
@@ -1,3 +1,4 @@
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using DeepDrftPublic.Client.Clients;
using Microsoft.AspNetCore.Components;
@@ -13,6 +14,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required BeaconInterop Beacon { get; set; }
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
[Inject] public required PublicSiteSettings Settings { get; set; }
private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService;
@@ -26,7 +28,12 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// EnsureInitializedAsync — that path is correct because audio contexts
// require a user gesture anyway. Initializing eagerly here causes 4+
// SignalR round-trips before any content is stable.
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
// Construct the preference-aware player (Phase 18 wave 18.6): it honours the listener's streaming-
// quality choice via the ResolveStreamFormatAsync seam while inheriting the 18.5 capability gate and
// C2 fallback. PublicSiteSettings is scoped data (already prerender-seeded + WASM-bridged), so passing
// it through the constructor is cheap and carries no lifecycle — the telemetry tracker still binds
// post-construction below, exactly as before.
var player = new PreferenceAwareStreamingPlayerService(AudioInterop, TrackMediaClient, Logger, Settings);
// Phase 16: bind the play-session tracker to the player after construction, the same way the
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
@@ -13,7 +13,7 @@
font-family: var(--deepdrft-font-mono);
font-size: 0.65rem;
letter-spacing: 0.28em;
color: var(--deepdrft-green-accent);
color: var(--deepdrft-green);
text-transform: uppercase;
margin-bottom: 1.8rem;
display: flex;
@@ -27,7 +27,7 @@
display: block;
width: 2.5rem;
height: 1px;
background: var(--deepdrft-green-accent);
background: var(--deepdrft-green);
}
.hero-title {
@@ -36,14 +36,14 @@
font-weight: 300;
line-height: 0.92;
letter-spacing: -0.02em;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
margin-bottom: 0.5rem;
animation-delay: 0.22s;
}
.hero-title em {
font-style: italic;
color: var(--deepdrft-green);
color: var(--deepdrft-green-accent);
}
.hero-subtitle {
@@ -51,7 +51,7 @@
font-size: clamp(1rem, 2vw, 1.35rem);
font-weight: 300;
font-style: italic;
color: var(--deepdrft-muted);
color: var(--deepdrft-page-text-muted);
margin-bottom: 3rem;
letter-spacing: 0.04em;
animation-delay: 0.34s;
@@ -61,7 +61,7 @@
font-family: var(--deepdrft-font-body);
font-size: 0.92rem;
line-height: 1.75;
color: var(--deepdrft-navy);
color: var(--deepdrft-page-text);
opacity: 0.7;
max-width: 36ch;
margin-bottom: 3rem;
@@ -81,3 +81,11 @@
align-items: stretch;
}
}
/* Dark-mode accent override (Phase 18, Wave 3).
.hero-title and .hero-desc bind --deepdrft-page-text directly above (theme-aware).
The em italic is the only element needing an explicit dark lift:
--deepdrft-green (#1A3C34) is low-contrast on the navy ground; lift to green-accent. */
:global(.deepdrft-theme-dark) .hero-title em {
color: var(--deepdrft-green-accent);
}
@@ -2,7 +2,7 @@
display: flex;
justify-content: center;
align-content: center;
background-color: var(--deepdrft-soft);
background-color: var(--deepdrft-play-chip);
border-radius: 50%;
height: 60px;
width: 60px;
@@ -10,5 +10,27 @@
}
.icon-container:hover {
background-color: color-mix(var(--deepdrft-soft), var(--deepdrft-navy-mid) 25%);
background-color: color-mix(in srgb, var(--deepdrft-play-chip), var(--deepdrft-navy-mid) 25%);
}
/* In dark mode the chip is moss-green and MudIconButton's Color.Primary/Secondary green
glyph would vanish against it, so pin the glyph to --deepdrft-play-glyph (navy) in dark
only. In light mode the token also resolves to navy, but applying it there overrides
Color.Secondary (green-accent) on hero/row mounts a visible regression. Scoping to
.deepdrft-theme-dark preserves the MudBlazor Color prop in light and fixes only dark.
::deep reaches the portaled-in-scope MudIconButton icon, which doesn't carry this
component's scope attribute. */
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button {
color: var(--deepdrft-play-glyph);
}
/* PlayStateIcon is authoritative over its own glyph colour a surrounding .dd-accent-icon
must NOT recolor the play-chip glyph in dark. The consolidation rule is:
.dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important
After Blazor scoped-CSS compilation this rule becomes:
.deepdrft-theme-dark .icon-container[b-xxx] .mud-icon-button .mud-icon-root (0,5,0) !important
(0,5,0) beats (0,3,0) wins on specificity; !important parity is irrelevant.
Dark only: light already renders the navy glyph via the MudBlazor Color prop. */
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button .mud-icon-root {
color: var(--deepdrft-play-glyph) !important;
}
@@ -71,6 +71,13 @@
private MudDropContainer<QueueRow>? _dropContainer;
// MudDropContainer snapshots its Items into internal drop zones and does not re-read them on a
// plain re-render — so a Clear/remove/reorder that changes the parent's Items list must be pushed
// into the container explicitly, or the panel shows the stale order until reopened (bug #4). The
// parent passes a fresh Items reference per mutation; refreshing here on every parameter set re-flows
// the container's snapshot to match. Cheap: Refresh only re-reads the bound list.
protected override void OnParametersSet() => _dropContainer?.Refresh();
// Index-tagged view rows. The index is the row's position in Items at render time and is the
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows =>
@@ -20,7 +20,7 @@
Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header">
<span class="deepdrft-queue-modal-title">Up Next</span>
<span class="deepdrft-queue-modal-title">Playlist</span>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
@@ -13,6 +13,7 @@ namespace DeepDrftPublic.Client.Controls;
public partial class ReleaseDetailScaffold : ComponentBase
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Parameter] public required string Title { get; set; }
[Parameter] public string? Artist { get; set; }
@@ -96,13 +97,19 @@ public partial class ReleaseDetailScaffold : ComponentBase
{
if (Track is null || PlayerService is null) return;
// Toggle if this track is already active (playing or paused); otherwise start a fresh
// stream. SelectTrackStreaming is the live entry point — the buffered path is dead.
// Toggle if this track is already active (playing or paused); otherwise PLAY it —
// prepend to the queue's front (deque PLAY semantics) so it becomes current and
// the existing queue stays intact behind it. Falls back to a direct stream when
// the queue cascade is absent (prerender / non-interactive).
var isThisTrack = PlayerService.CurrentTrack?.Id == Track.Id;
if (isThisTrack && (PlayerService.IsPlaying || PlayerService.IsPaused))
{
await PlayerService.TogglePlayPause();
}
else if (Queue is not null)
{
await Queue.PlayTrack(Track);
}
else
{
await PlayerService.SelectTrackStreaming(Track);
@@ -52,7 +52,7 @@
</MudStack>
@if (ShareContent is not null)
{
<div class="release-hero-share">
<div class="release-hero-share dd-accent-icon">
@ShareContent
</div>
}
@@ -74,7 +74,7 @@
</div>
@if (PlayContent is not null)
{
<div class="release-hero-play">
<div class="release-hero-play dd-accent-icon">
@PlayContent
</div>
}
@@ -151,14 +151,13 @@
flex: 0 0 auto;
}
/* The play affordance and share button sit over a dark image force their icon glyphs to the
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
::deep .release-hero-play .mud-icon-button,
::deep .release-hero-play .mud-progress-circular,
::deep .release-hero-share .mud-icon-button {
color: var(--deepdrft-white);
}
/* The play/share glyphs are coloured by the shared .dd-accent-icon treatment (green-accent in
both themes) applied on .release-hero-play / .release-hero-share in ReleaseHeroOverlay.razor
see deepdrft-styles.css. No co-located colour rule here: the former white override was removed
because its glyph clauses (.mud-icon-button .mud-icon-root) could not reach the
.mud-secondary-text !important glyph at wrapper specificity, and its spinner clause
(.mud-progress-circular) was live but is now correctly covered by .dd-accent-icon
making the spinner green-accent (was white) in light mode, the one intentional light delta. */
@media (max-width: 599.98px) {
.release-hero {
@@ -0,0 +1,116 @@
@using DeepDrftPublic.Client.Common
@inject SeoOptions Seo
@inject SeoEnvironment SeoEnv
@inject NavigationManager Nav
@*
The single reusable SEO head surface (Phase 22). Presentational and parameter-fed — owns no fetch and
no business logic (C4); it reads the injected SeoOptions for defaults and NavigationManager for the
current path. Renders <PageTitle> (the sole title source — pages drop their bare <PageTitle>) plus a
<HeadContent> block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
<HeadOutlet> in App.razor so it is present in the prerendered HTML a crawler sees (C2/AC1).
Identical output across the InteractiveAuto double render (AC6): every value comes from the parameter
Model (built from the page's bridged PersistentComponentState) and config — never a browser API — so
the prerender and WASM passes render byte-identical tags.
Partial data (C6/AC4): a missing value falls back to config or omits its tag; og:image always resolves
(the default guarantees presence) so there is never a content="" attribute or a broken node.
*@
<PageTitle>@_fullTitle</PageTitle>
<HeadContent>
@* Standard / search *@
<meta name="description" content="@_description" />
<link rel="canonical" href="@_canonical" />
<meta name="robots" content="@_robots" />
<meta name="application-name" content="@Seo.SiteName" />
@* Open Graph *@
<meta property="og:title" content="@Model.Title" />
<meta property="og:description" content="@_description" />
<meta property="og:url" content="@_canonical" />
<meta property="og:type" content="@_ogType" />
<meta property="og:site_name" content="@Seo.SiteName" />
<meta property="og:locale" content="@Seo.Locale" />
<meta property="og:image" content="@_image" />
@if (_hasCover)
{
<meta property="og:image:alt" content="@($"{Model.Title} cover art")" />
}
@* Music-vertical OG (release pages only) *@
@if (!string.IsNullOrWhiteSpace(Model.Artist))
{
<meta property="music:musician" content="@Model.Artist" />
}
@if (Model.ReleaseDate is not null)
{
<meta property="music:release_date" content="@Model.ReleaseDate.Value.ToString("yyyy-MM-dd")" />
}
@if (_isoDuration is not null)
{
<meta property="music:duration" content="@_isoDuration" />
}
@* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@Model.Title" />
<meta name="twitter:description" content="@_description" />
<meta name="twitter:image" content="@_image" />
@* JSON-LD structured data *@
@if (!string.IsNullOrEmpty(Model.JsonLd))
{
<script type="application/ld+json">@((MarkupString)Model.JsonLd)</script>
}
</HeadContent>
@code {
/// <summary>The page's resolved SEO input, built via a <see cref="SeoModel"/> factory.</summary>
[Parameter, EditorRequired] public required SeoModel Model { get; set; }
private string _fullTitle = string.Empty;
private string _description = string.Empty;
private string _canonical = string.Empty;
private string _robots = string.Empty;
private string _ogType = "website";
private string _image = string.Empty;
private bool _hasCover;
private string? _isoDuration;
protected override void OnParametersSet()
{
_fullTitle = $"{Model.Title} · {Seo.TitleSuffix}";
_description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description;
// Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
// crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots;
_ogType = OgTypeString(Model.OgType);
// Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is
// always config (no browser API) so prerender and WASM agree (§5).
var path = Model.CanonicalPath ?? RelativePath();
_canonical = SeoUrls.Absolute(Seo, path);
_hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath);
_image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath);
_isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds);
}
private string RelativePath()
{
var path = Nav.ToBaseRelativePath(Nav.Uri);
var query = path.IndexOf('?');
if (query >= 0) path = path[..query];
return "/" + path;
}
private static string OgTypeString(SeoOgType type) => type switch
{
SeoOgType.MusicAlbum => "music.album",
SeoOgType.MusicSong => "music.song",
_ => "website",
};
}
@@ -0,0 +1,55 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls.Settings
@using DeepDrftPublic.Client.Services
@*
The public-site Settings menu (Phase 18 wave 18.6, §4a). An app-bar trigger opening a MudMenu that renders
a settings-item list — NOT a hard-coded control stack. Each entry is a SettingsItem (label + a control
fragment bound to a persisted preference), so a future tenant (dark mode) plugs in as a new list entry, not
a menu rewire. Today the list holds one item: the streaming-quality toggle.
The MudMenu items carry OnClick="@(() => {})" + OnTouch so a click inside a control row does not dismiss the
menu (MudMenu auto-closes on item activation otherwise), keeping the radio group usable.
*@
<div class="dd-accent-icon">
<MudMenu Icon="@Icons.Material.Filled.Settings"
Color="Color.Inherit"
AnchorOrigin="Origin.BottomRight"
TransformOrigin="Origin.TopRight"
AriaLabel="Settings"
Class="dd-settings-menu">
<div class="dd-settings-panel">
<div class="dd-settings-heading">Settings</div>
@foreach (var item in _items)
{
<div class="dd-settings-item">
<div class="dd-settings-item-label">@item.Label</div>
@item.Control
</div>
}
</div>
</MudMenu>
</div>
@code {
// The active player, cascaded by AudioPlayerProvider. SettingsMenu sits in the app bar inside the
// provider, so it receives the cascade here — but the MudMenu PANEL content below is portaled to
// <MudPopoverProvider> (outside the provider), so a cascade cannot reach it. We thread the player into
// the setting as an explicit parameter instead: an explicit value captured in the item fragment flows
// into portaled content fine, where a [CascadingParameter] would land null.
[CascadingParameter] public IStreamingPlayerService? Player { get; set; }
// The settings-item list. Built once; adding a preference is appending one SettingsItem with its control
// fragment — the menu body above renders whatever is here without knowing what each item is. The item
// fragment reads Player at render time (when the menu opens), so it picks up the cascaded instance even
// though the list itself is initialized before the cascade is set.
private readonly List<SettingsItem> _items;
public SettingsMenu()
{
_items =
[
new SettingsItem("Streaming quality", @<StreamQualitySetting Player="@Player" />)
];
}
}
@@ -0,0 +1,97 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Services
@*
The streaming-quality control (Phase 18 wave 18.6, §4) — the first occupant of the Settings menu. Binds
the listener's choice to PublicSiteSettings.StreamQuality and persists it via the cookie seam. Honest
capability gate (OQ2 / AC7): on a browser that cannot decode Ogg Opus the Low-data option still selects,
but a note tells the listener the effective stream is lossless — we never let the choice silently imply a
format that can't play.
*@
<div class="dd-setting-control">
<MudRadioGroup T="StreamQuality" Value="_quality" ValueChanged="OnQualityChanged">
<MudRadio T="StreamQuality" Value="StreamQuality.LowData" Color="Color.Primary" Dense="true">
Low-data (Opus)
</MudRadio>
<MudRadio T="StreamQuality" Value="StreamQuality.Lossless" Color="Color.Primary" Dense="true">
Lossless (WAV)
</MudRadio>
</MudRadioGroup>
@if (_opusUnavailable && _quality == StreamQuality.LowData)
{
<div class="dd-setting-note">
This browser can't decode Opus — you'll stream lossless.
</div>
}
<div class="d-flex flex-align-right">
<MudButton Disabled="!IsApplyEnabled"
Color="Color.Primary"
Variant="Variant.Filled"
OnClick="@ApplyStreamQualitySetting">
APPLY
</MudButton>
</div>
</div>
@code {
[Inject] public required PublicSiteSettings Settings { get; set; }
[Inject] public required SettingsCookieService CookieService { get; set; }
[Inject] public required AudioInteropService AudioInterop { get; set; }
// The active player, threaded in from SettingsMenu (which reads it off the AudioPlayerProvider cascade).
// It is an explicit [Parameter], NOT a [CascadingParameter], because this control renders inside the
// MudMenu panel, which MudBlazor portals to <MudPopoverProvider> — outside AudioPlayerProvider's cascade
// scope, so a cascade would land null here. Null during prerender or when no provider is present — Apply
// then just persists the preference, with no live track to restart.
[Parameter] public IStreamingPlayerService? Player { get; set; }
private StreamQuality _quality;
public bool IsApplyEnabled => _quality != Settings.StreamQuality;
// Null until the capability probe runs (post-render JS interop). false → can decode Opus; true → cannot.
private bool _opusUnavailable;
protected override void OnInitialized()
{
// Read the current preference (already seeded at prerender + bridged into WASM).
_quality = Settings.StreamQuality;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// Capability probe is JS interop — only valid once interactive. Surfaces the honest note when the
// browser can't decode Ogg Opus, so a Low-data pick reads as "effectively lossless" rather than
// silently failing. The player applies the same gate independently; this is purely the UI honesty.
var canDecodeOpus = await AudioInterop.CanDecodeOggOpus();
if (canDecodeOpus == _opusUnavailable)
{
_opusUnavailable = !canDecodeOpus;
StateHasChanged();
}
}
private async Task OnQualityChanged(StreamQuality quality)
{
_quality = quality;
}
private async Task ApplyStreamQualitySetting(MouseEventArgs arg)
{
// Persist the choice first so the cookie + in-memory PublicSiteSettings.StreamQuality both reflect
// the new value BEFORE the restart: the reload re-resolves the delivery format via
// PreferenceAwareStreamingPlayerService, which reads PublicSiteSettings.StreamQuality fresh.
await CookieService.SetStreamQualityAsync(_quality);
// Switch the currently-playing track to the new format immediately, preserving the listener's
// position. No-op inside the player when nothing is loaded — the new preference then simply
// applies to the next track played. Fire-and-forget: the reload runs the streaming loop for the
// life of the track, so awaiting it would pin this handler open; UI updates flow through the
// player's state notifications, not this await.
_ = Player?.ReloadPreservingPositionAsync();
}
}
@@ -27,6 +27,7 @@
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
[Parameter] public EventCallback OnStreamStarted { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
private bool _streamLoading;
@@ -79,7 +80,12 @@
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
// PLAY semantics: prepend to the queue's front so a "stream now" track becomes current and
// any existing queue stays intact behind it. Falls back to a direct stream when the queue
// cascade is absent.
if (Queue is not null)
await Queue.PlayTrack(track);
else if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
@@ -0,0 +1,59 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@implements IDisposable
@inject WaveformVisualizerControlState State
@* Theater-Mode toggle (Phase 20 §3). The single affordance placed identically on all three release
detail pages — immediately to the LEFT of the lava-lamp WaveformVisualizerControlPopover trigger.
It is purely a mutation surface: tapping it flips State.TheaterMode and raises Changed; the detail
pages observe that to gate their content @if, and the player bar observes it to grow. This component
reaches into no page and no bar — single source, multiple observers (§6).
Visible only when the lava OR waveform subsystem is on — there is nothing to go to theater FOR if both
are off (§3.2) — AND when <see cref="Available"/> is true. The page supplies Available so the toggle
only appears when this page's release is the one playing (Phase 20 Wave 2 §3): the toggle owns the
subsystem gate; the page owns the release-playing predicate. Disabled until interactive (§3.4), the
same prerender guard the lava/Play buttons use. Active visual state when Theater is ON. .dd-accent-icon
gives the green-accent glyph in both themes with zero new CSS (§8) — same as the lava-lamp trigger. *@
@if (Available && (State.LavaEnabled || State.WaveformEnabled))
{
<div class="dd-accent-icon">
<MudTooltip Text="@(State.TheaterMode ? "Exit theater mode" : "Theater mode")">
<MudIconButton Icon="@Icons.Material.Filled.Theaters"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Theater mode"
aria-pressed="@State.TheaterMode" />
</MudTooltip>
</div>
}
@code {
/// <summary>Trigger-icon size. Defaults Large to match the lava-lamp popover trigger it sits beside.</summary>
[Parameter] public Size IconSize { get; set; } = Size.Large;
/// <summary>
/// Whether the toggle is available on this surface (Phase 20 Wave 2 §3). The page passes the
/// "this release is the one playing" predicate here; Theater Mode only applies to the playing
/// release, so a detail page whose release is not playing passes <c>false</c> and shows no toggle.
/// Defaults <c>true</c> so surfaces with no release-scoping (none today) keep the subsystem-only gate.
/// </summary>
[Parameter] public bool Available { get; set; } = true;
protected override void OnInitialized() => State.Changed += OnStateChanged;
// The toggle's own visibility and active state both key off State, which another observer (or this
// button) may mutate, so re-render on every Changed — same idempotent posture the visualizer bridge uses.
private void OnStateChanged() => InvokeAsync(StateHasChanged);
private void Toggle()
{
State.TheaterMode = !State.TheaterMode;
State.NotifyChanged();
}
public void Dispose() => State.Changed -= OnStateChanged;
}
@@ -265,6 +265,24 @@ public partial class WaveformVisualizer : ComponentBase, IAsyncDisposable
return;
}
// Apply the hardware-capability default ONCE per session before seeding the controls: if the
// browser has no WebGL hardware acceleration, the lava subsystem (which software-renders on
// the main thread and starves audio decode) defaults off while the waveform stays on. The
// probe lives in JS (it needs a real WebGL context); the scoped state guards the one-time
// application, so a remounted visualizer never re-applies and a later explicit toggle is
// never clobbered. Sequenced before PushControlsAsync so the seed already carries the
// corrected enables; ApplyCapabilityDefault also raises Changed for the controls UI.
try
{
var hardwareAccelerated = await _module.InvokeAsync<bool>("detectHardwareAcceleration");
ControlState.ApplyCapabilityDefault(hardwareAccelerated);
}
catch (JSException ex)
{
// A probe failure must not regress the HW-accel majority: leave the defaults (lava on).
Logger.LogWarning(ex, "WaveformVisualizer: hardware-acceleration probe failed; leaving lava at its default.");
}
// Seed the module with the current state now that it exists. All control values (the eight
// dials + the two Phase 15 subsystem enables) come from the shared (session-persisted) state,
// so a mix opened mid-session seeds the module with the knob/toggle positions the listener
@@ -1,19 +1,20 @@
/* Full-viewport fixed backdrop. Sits behind the detail content (.mix-detail-foreground is z-index:1)
and never intercepts pointer events except the zoom slider, which re-enables them on itself.
Footer clip (Phase 10 W1, spec §2c): the backdrop must stop cleanly ABOVE the audio player bar so
no lava/waveform pixel paints over or under it. `overflow: hidden` clips the canvas to this box, and
`bottom` is inset by `--player-height`, which AudioPlayerBar publishes on :root via its ResizeObserver
(Interop/layout/spacer.ts). The observer now points at whichever element is live:
expanded the full player dock (tracks breakpoint reflow + error-banner growth)
minimized the minimized-dock FAB container (~5660 px)
so --player-height is always non-zero while the player is mounted and the clip line follows the bar in
BOTH states (fix §1 / p10-reframe-w1-fix). The 0px fallback keeps the backdrop full-height on any
page that does not host the player. */
Anchored to the viewport bottom (`inset: 0` fills the whole screen). The chrome that must occlude
it paints OVER it on z-index, not by clipping the box: the app bar (z 100), the docked player bar
(z 1200/1300), the site footer (z 1 stacking context), and the layout spacer (z 1 stacking context,
`--deepdrft-page-surface` background) all sit above this z-0 layer. Where those elements are inset
from the screen edges or scrolled below the fold, the visualizer fills the gap continuously no
page-background strip around the inset player bar. `overflow: hidden` clips the canvas to this box.
NOTE: this box is decoupled from `--player-height` (it no longer reads that var). The renderer's own
ResizeObserver therefore never fires on a player-bar height change, so an eased Theater-Mode collapse
can no longer clear the GL backing store mid-ease (the former theater-flash source). See
Interop/layout/spacer.ts. */
.mix-waveform-bg {
position: fixed;
inset: 0;
bottom: var(--player-height, 0px);
z-index: 0;
pointer-events: none;
overflow: hidden;
@@ -29,15 +29,17 @@
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
host only toggles open/closed and centers the panel — it stays purely presentational. *@
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
<div class="dd-accent-icon">
<MudTooltip Text="Visualizer settings">
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
Size="@IconSize"
Color="Color.Secondary"
Disabled="@(!RendererInfo.IsInteractive)"
OnClick="@Toggle"
aria-label="Visualizer settings"
aria-expanded="@_open" />
</MudTooltip>
</div>
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click

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