316 Commits

Author SHA1 Message Date
daniel-c-harvey 80ebc80a2a fix: Home Page Styles Cleanup
Deploy DeepDrftPublic / Build & Publish (push) Failing after 3m27s
Deploy DeepDrftPublic / Deploy (push) Has been skipped
2026-06-11 19:59:08 -04:00
daniel-c-harvey 68bf328e7c docs: add Phase 8 §8.7 to COMPLETED (upload cache invalidation + Albums→Releases rename)
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Failing after 3m29s
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m59s
Deploy DeepDrftManager / Deploy (push) Successful in 1m23s
Deploy DeepDrftPublic / Deploy (push) Has been skipped
Deploy DeepDrftAPI / Deploy (push) Successful in 1m36s
2026-06-11 18:57:03 -04:00
daniel-c-harvey b5bd1c977b Merge branch 'p8-w7-release-label-upload-stale' into dev 2026-06-11 18:55:50 -04:00
daniel-c-harvey 4b26e0a969 fix: invalidate VM cache after upload and rename Albums tab/card to Releases 2026-06-11 18:55:36 -04:00
daniel-c-harvey c6078a3e71 assets: add studio, live, and DJ mix images for home medium section 2026-06-11 18:50:07 -04:00
daniel-c-harvey 0874042040 docs: move Phase 8.6 from PLAN to COMPLETED, correct type labels to Studio/Live/Mix 2026-06-11 18:49:58 -04:00
daniel-c-harvey 6d3b9cd4d3 Merge branch 'p8-w6-medium-section' into dev 2026-06-11 18:38:10 -04:00
daniel-c-harvey 9792d4346e docs: add Phase 8 §8.6 to COMPLETED (cache invalidation + orphaned release fixes) 2026-06-11 17:58:31 -04:00
daniel-c-harvey fc20a5d3d2 Merge branch 'p8-w6-stale-refresh-album-delete' into dev 2026-06-11 17:57:08 -04:00
daniel-c-harvey f02974b3c2 fix: refresh stale browse cache on track edits and allow deleting empty releases
- Add CmsTrackBrowserViewModel.Invalidate(); called from TrackEdit/BatchEdit on save or delete so album/genre cache is invalidated and re-fetches on next mode switch
- CmsAlbumBrowser now handles 0-track releases: confirm dialog + DeleteReleaseAsync instead of early return; partial-failure path also fires OnReleasesChanged to trigger cache invalidation
- TrackList.OnAlbumsChanged now calls VM.Invalidate() so genres stay fresh after any album delete
- UnifiedTrackService.DeleteAsync cascades release soft-delete when last live track is removed (non-fatal; logs on failure)
- New DELETE api/track/release/{id} endpoint (ApiKeyAuthorize) for direct release soft-delete
- EF migration SoftDeleteOrphanedReleases backfills existing orphaned release rows via raw SQL (data-only, no schema change)
2026-06-11 17:56:18 -04:00
daniel-c-harvey a6e565e445 feat: replace home genre cards with Music through Every Medium image section 2026-06-11 17:55:13 -04:00
daniel-c-harvey 38e345ccf7 docs: add Phase 8.6 'Music through Every Medium' section spec to PLAN 2026-06-11 17:48:25 -04:00
daniel-c-harvey fd8c0e389f fix: correct missing @ directives on ExpandedGenre bindings in genre browser 2026-06-11 17:30:11 -04:00
daniel-c-harvey b359786e69 docs: move Phase 8 §8.1-§8.5 from PLAN to COMPLETED (landed 2026-06-12) 2026-06-11 17:00:55 -04:00
daniel-c-harvey bef3f590ca Merge branch 'p8-w5c-batch-edit' into dev 2026-06-11 16:57:18 -04:00
daniel-c-harvey 407ed90341 feat: add BatchEdit page and extract reusable batch sub-components from BatchUpload
fix: TrackNumber sort case, stale _imagePath reset, skip Done rows on retry in BatchEdit
2026-06-11 16:56:55 -04:00
daniel-c-harvey 92a3bea129 Merge branch 'p8-w5b-t2-genre-browser' into dev 2026-06-11 16:45:10 -04:00
daniel-c-harvey 6480953189 Merge branch 'p8-w5b-t1-album-browser' into dev 2026-06-11 16:45:07 -04:00
daniel-c-harvey b22c3f96d7 feat: add CmsGenreBrowser genre browse mode to CMS track list
fix: add @key="ExpandedGenre" to CmsTrackGrid so genre switch forces fresh component instance
2026-06-11 16:42:46 -04:00
daniel-c-harvey 62620bc0d4 feat(cms): add expandable Album browser to Track Browser 2026-06-11 16:26:44 -04:00
daniel-c-harvey 55b26b2e41 Merge branch 'p8-w5a-foundation' into dev 2026-06-11 16:18:19 -04:00
daniel-c-harvey 508a522a8d feat(cms): add Track Browser foundation with mode toggle and CmsTrackGrid
- Extend ICmsTrackService.GetPagedAsync with album/genre filter params
- Add CmsTrackBrowserViewModel (DI-scoped) with lazy album/genre load
- Extract CmsTrackGrid: 9-column layout, waveform status, per-row generate,
  info tooltip, album/genre filter params, OnStatusLoaded callback
- Restructure TrackList: remove MudTabs, add three @page routes, mode toggle,
  Generate All Missing button; album/genre stubs for next wave
2026-06-11 16:17:45 -04:00
daniel-c-harvey cf557e16aa Merge branch 'p7-w6-parallax-pop' into dev 2026-06-11 16:09:34 -04:00
daniel-c-harvey a2f9742f8a fix(parallax): prime parallax position with pre-Blazor init script to kill Server->WASM position pop 2026-06-11 16:08:55 -04:00
daniel-c-harvey a29b961c27 docs: move Phase 8 §8.0 to COMPLETED; unblock §8.1-§8.5 in PLAN 2026-06-11 15:58:30 -04:00
daniel-c-harvey e077b8ec7b Merge branch 'p8-w12-release-track-normalize' into dev 2026-06-11 15:55:10 -04:00
daniel-c-harvey 612b21b1e7 Merge branch 'p7-w5-parallax-prerender-pos' into dev 2026-06-11 15:47:48 -04:00
daniel-c-harvey 70d4a87cd5 fix: include Release nav on all TrackRepository query paths; add unique constraint on release(title, artist) 2026-06-11 14:48:52 -04:00
daniel-c-harvey ae531116b7 fix(parallax): animate background-position-y directly so SSR parallax works pre-WASM 2026-06-11 14:45:30 -04:00
daniel-c-harvey 63bdc5ee93 feature: Home Pictures part 1 2026-06-11 13:47:41 -04:00
daniel-c-harvey f767d288c5 feat: normalize release-cardinal fields out of track into a Release entity (Phase 8 §8.0) 2026-06-11 12:51:21 -04:00
daniel-c-harvey 9d7f2ff003 feat(home): wire ParallaxImage hero to homepage; tweak crossfade to 700ms 2026-06-11 12:12:35 -04:00
daniel-c-harvey c59f59c3fe Merge branch 'p7-w4-parallax-css-scroll' into dev 2026-06-11 12:12:05 -04:00
daniel-c-harvey 91566692f6 fix(parallax): drive --parallax-pos via CSS scroll animation to kill SSR/hydration position pop 2026-06-11 11:57:43 -04:00
daniel-c-harvey 16f356a760 docs: resolve TrackDto nesting (§0.3) and add §8.0 wave sequencing
Resolve Phase 8 open question 0.3 — TrackDto gets a nested Release
(ReleaseDto); flat release fields removed, all consumers updated as
part of §8.0 (flat read-model rejected). Add §0.6 implementation
sequencing: five mergeable waves with Waves 1+2 as a single deployment
unit and Waves 3+4 parallelizable. Update PLAN.md §8.0 Shape to match.
2026-06-11 11:09:24 -04:00
daniel-c-harvey 8983592e56 Merge branch 'p7-w3-parallax-ssr' into dev 2026-06-11 11:07:55 -04:00
daniel-c-harvey 92ddc5bb3e fix(parallax): add aspect-ratio mode to ParallaxImage to kill SSR/hydration layout shift 2026-06-11 11:06:16 -04:00
daniel-c-harvey 76e5080278 docs: gate Phase 8 on TrackEntity normalization (§8.0); fold review decisions
Add §8.0 TrackEntity → Release/Track normalization as a breaking
pre-requisite before Phase 8 UI. Fold in review decisions: Waveform tab
removed (in-grid status column + per-row/page-level generate),
ViewModel is DI-scoped (TracksViewModel pattern), BatchEdit confirmed as
a new page sharing extracted sub-components. Dissolve the AlbumSummaryDto
widening question (Release table supplies the fields directly).
2026-06-11 11:03:48 -04:00
daniel-c-harvey 675710d086 Merge branch 'p7-w2-parallax-aspect-ratio' into dev 2026-06-11 10:24:03 -04:00
daniel-c-harvey c46c3a2f9c feat(parallax): aspect-ratio-aware auto height via WindowHeightFraction + ResizeObserver, drop DotNetObjectReference round-trip 2026-06-11 10:23:25 -04:00
daniel-c-harvey 49e99ff986 docs: add Phase 8 (CMS Track Browser) to PLAN; supersede §6.2 2026-06-11 09:49:19 -04:00
daniel-c-harvey 5a345cabea docs(plan): move Phase 1.2 audio format diversity to COMPLETED.md 2026-06-11 09:45:03 -04:00
daniel-c-harvey 25ade16b07 Merge branch 'p1.2-w3-factory-wiring' into dev 2026-06-11 09:42:05 -04:00
daniel-c-harvey 5d9ba1c953 feat(audio): wire Mp3FormatDecoder and FlacFormatDecoder into AudioPlayer factory 2026-06-11 09:32:33 -04:00
daniel-c-harvey ab418bf840 docs: move ParallaxImage 7.1 from PLAN to COMPLETED (landed 2026-06-11) 2026-06-11 09:28:22 -04:00
daniel-c-harvey d3f1d6a8a0 Merge branch 'p7-w1-parallax-image' into dev 2026-06-11 09:24:16 -04:00
daniel-c-harvey 4d9505c341 feat: add ParallaxImage scroll-parallax component to DeepDrftShared.Client 2026-06-11 09:23:34 -04:00
daniel-c-harvey 0439d3da4f docs: record Phase 1.2 Wave 2 progress; update PLAN.md and DeepDrftPublic.Client CLAUDE.md 2026-06-11 09:13:04 -04:00
daniel-c-harvey 98142754fa Merge branch 'fix-upload-field-name' into dev 2026-06-11 09:10:55 -04:00
daniel-c-harvey 3da12067f6 fix: match multipart field name "audioFile" to API [FromForm] binding in UploadTrackAsync 2026-06-11 09:10:50 -04:00
daniel-c-harvey 86e1243eba Merge branch 'p1.2-w2-t2-flac-decoder' into dev 2026-06-11 09:08:49 -04:00
daniel-c-harvey b6b212e429 Merge branch 'p1.2-w2-t1-mp3-decoder' into dev 2026-06-11 09:08:40 -04:00
daniel-c-harvey 879c30a5e5 fix(flac): add FLAC frame-sync scan to getAlignedSegmentSize; extend IFormatDecoder rawData param
StreamDecoder peeks candidate bytes; FlacFormatDecoder scans backward for 0xFF/0xF8 sync. Fixes mid-stream decode failure where segments started mid-frame.
2026-06-11 09:08:33 -04:00
daniel-c-harvey a2771c71aa fix(mp3): guard sub-frame tail in getAlignedSegmentSize to prevent over-read past availableBytes 2026-06-11 09:04:53 -04:00
daniel-c-harvey 81b8796ba5 fix: send ReleaseType as int in CmsTrackService.UpdateAsync 2026-06-11 08:50:10 -04:00
daniel-c-harvey 489215e415 fix: send ReleaseType as int not string in CmsTrackService.UpdateAsync 2026-06-11 08:49:00 -04:00
daniel-c-harvey b7b5933b25 docs(parallax): fold in resolved JS-placement and direction decisions
Resolve two open questions in the ParallaxImage spec: TS toolchain
co-located in DeepDrftShared.Client (Interop/parallax -> wwwroot/js), and
parallax direction exposed as the InvertDirection parameter. Update PLAN.md
7.1 constraint to reflect no remaining blockers.
2026-06-11 08:48:57 -04:00
daniel-c-harvey c4930e80ba feat(audio): add FLAC IFormatDecoder for chunked streaming + seek 2026-06-11 08:40:53 -04:00
daniel-c-harvey b04081b960 feat(audio): add Mp3FormatDecoder streaming strategy
Implements IFormatDecoder for MP3: ID3v2 skip, MPEG Layer III frame-sync + header decode, Xing/Info/VBRI detection, CBR frame alignment, and VBR TOC seek interpolation. Wiring lands in Wave 3.
2026-06-11 08:40:52 -04:00
daniel-c-harvey bd6bd4d827 docs(plan): spec ParallaxImage shared component (Phase 7)
Add product note and PLAN.md Phase 7 entry for a reusable scroll-parallax
image window in DeepDrftShared.Client — full-width flag, hover crossfade,
IntersectionObserver-gated scroll math, accessibility.
2026-06-11 08:36:00 -04:00
daniel-c-harvey c835a54652 docs: record Phase 1.2 Wave 1 progress; update processor, client, and API CLAUDE.md 2026-06-11 08:23:56 -04:00
daniel-c-harvey 909d259df9 Merge branch 'p1.2-w1-t2-decoder-interface' into dev 2026-06-11 08:20:12 -04:00
daniel-c-harvey f10e20a0e2 Merge branch 'p1.2-w1-t1-format-processors' into dev
# Conflicts:
#	DeepDrftAPI/Controllers/TrackController.cs
2026-06-11 08:20:05 -04:00
daniel-c-harvey 009f565b73 fix: remove dead CalculateByteOffset C# shim; guard AudioPlayer.calculateByteOffset on parsed format 2026-06-11 06:13:52 -04:00
daniel-c-harvey 4a46ec36b3 fix(mp3): remove dead FrameSize field, fix CBR duration ID3 exclusion, add MPEG2 bitrate table, pin CBR test assertions 2026-06-11 06:13:20 -04:00
daniel-c-harvey 0b0bcb3dee refactor(audio): extract IFormatDecoder/WavFormatDecoder and wire Content-Type to JS format selection
StreamDecoder is now format-agnostic; WavFormatDecoder delegates to WavUtils; contentType flows C# to JS.
2026-06-11 06:08:09 -04:00
daniel-c-harvey 34e7f2f8ed docs(plan): move Phase 6 CMS Enhancements (6.1 dashboard, 6.3 batch upload) to COMPLETED.md 2026-06-11 05:49:33 -04:00
daniel-c-harvey 3bb8104967 feat(audio): add MP3 and FLAC upload support via format-routed processors
AudioProcessorRouter dispatches by extension; vault stores original bytes with correct MIME type.
2026-06-11 05:49:17 -04:00
daniel-c-harvey a82bd875d9 Merge branch 'p6-w2-batch-upload': batch upload page 2026-06-10 21:44:47 -04:00
daniel-c-harvey 72171c9374 feat(cms): add batch upload page for multi-track releases at /tracks/upload 2026-06-10 21:43:31 -04:00
daniel-c-harvey 480c961a09 Merge branch 'p6-w1-t2-data-model': ReleaseType + TrackNumber data model 2026-06-10 21:36:28 -04:00
daniel-c-harvey 754dc311a6 Merge branch 'p6-w1-t1-cms-dashboard': CMS home dashboard 2026-06-10 21:36:17 -04:00
daniel-c-harvey d47a5e00af feat(tracks): add ReleaseType and TrackNumber to track metadata model and CMS edit form 2026-06-10 21:36:00 -04:00
daniel-c-harvey 77dee5eac5 feat(cms): replace home redirect with catalogue dashboard of track/album/genre cards 2026-06-10 21:35:59 -04:00
daniel-c-harvey f8186fb7c7 docs: move Phase 1.1 to COMPLETED.md; update DeepDrftContent CLAUDE.md for float and padded WAV support 2026-06-10 20:42:58 -04:00
daniel-c-harvey 092ac0b5f2 Merge branch 'p1-w1-wav-format-extensions' into dev 2026-06-10 20:41:04 -04:00
daniel-c-harvey 3953229ae4 docs(plan): confirm Phase 6 batch-upload decisions; renumber CMS Enhancements
Renumber CMS Enhancements section to Phase 6 (6.1-6.3). Resolve three
6.3 open questions: one album per batch (all release fields shared in
header), persistent track ordinals via new TrackNumber field, and artist
as a release-level header field. Drag-and-drop reorder remains the only
open question.
2026-06-10 20:40:42 -04:00
daniel-c-harvey 8d80d43a47 test: assert data region in float and padded-container conversion tests; add TryExtractPcm null-return coverage 2026-06-10 20:08:45 -04:00
daniel-c-harvey eddbb00cd9 feat(audio): accept EXTENSIBLE IEEE-float and padded 24-in-32 WAV
Convert float to 24-bit PCM and repack padded containers on normalize; vault still stores standard PCM.
2026-06-10 20:04:55 -04:00
daniel-c-harvey aa1f7d50f1 docs(plan): spec Phase 2 CMS enhancements — home dashboard and batch upload 2026-06-10 19:34:10 -04:00
daniel-c-harvey b4cda76114 fix: Cover Art Upload Enable State
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m1s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m0s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m23s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-10 19:21:39 -04:00
daniel-c-harvey 38529a962a docs(plan): add Phase 1.1 Extended WAV format support
Track the two EXTENSIBLE WAV sub-cases scoped out of the
WAVE_FORMAT_EXTENSIBLE PCM fix: non-PCM (IEEE Float) SubFormats and
padded 24-in-32 containers.
2026-06-10 15:31:25 -04:00
daniel-c-harvey d2a9475ba2 Merge branch 'extensible-wav-support' into dev 2026-06-10 15:27:47 -04:00
daniel-c-harvey e84823be39 docs: update AudioProcessor notes for EXTENSIBLE-PCM WAV support 2026-06-10 15:26:50 -04:00
daniel-c-harvey 6c602170a9 fix(audio): guard EXTENSIBLE fmt OOB read on truncated buffer; document padded-container gap 2026-06-10 15:24:31 -04:00
daniel-c-harvey 88ac5b2c88 fix(audio): support WAVE_FORMAT_EXTENSIBLE PCM WAVs, normalizing them to standard PCM on upload 2026-06-10 15:20:34 -04:00
daniel-c-harvey 0f5eaa42b5 Merge branch 'soft-delete-fix' into dev 2026-06-10 14:32:50 -04:00
daniel-c-harvey f0185587f7 fix(data): route all TrackRepository queries through soft-delete-filtered Query 2026-06-10 14:32:31 -04:00
daniel-c-harvey 0a5ddfdad8 Merge branch 'seek-load-race' into dev 2026-06-10 14:31:48 -04:00
daniel-c-harvey 8b94a5fdf7 fix: assign seek CTS synchronously and guard load finally to stop seek/load race 2026-06-10 14:30:12 -04:00
daniel-c-harvey fb27918ed6 fix: guard LoadTrackStreaming OCE catch with loadCts identity so an in-flight seek isn't clobbered mid-load 2026-06-10 14:22:35 -04:00
daniel-c-harvey 691d904273 Merge branch 'track-new-image' into dev 2026-06-10 14:18:44 -04:00
daniel-c-harvey ded5a3e5eb feat(manager): add optional cover art upload to Add Track form 2026-06-10 14:14:35 -04:00
daniel-c-harvey f25d0f624f Merge branch 'seek-hardening' into dev 2026-06-10 13:25:17 -04:00
daniel-c-harvey 43f54cb950 Merge branch 'p2-w1-filter-views' into dev 2026-06-10 12:43:33 -04:00
daniel-c-harvey f40940b957 fix: guard SeekBeyondBuffer OCE catch with when(seekCts.IsCancellationRequested) so timeout OCEs fall through to error handler 2026-06-10 11:08:54 -04:00
daniel-c-harvey 10256677ac docs: close Phase 2.2/2.3 — move to COMPLETED.md, update DeepDrftPublic proxy CLAUDE.md 2026-06-10 10:58:16 -04:00
daniel-c-harvey 6fe7663667 fix: harden seek — timeout no longer swallowed as cancel, rapid seek-on-seek no longer clears active seek flag 2026-06-10 10:55:49 -04:00
daniel-c-harvey 5cae83b9ed feat: add search/album/genre filtering and /albums + /genres browse pages 2026-06-10 10:54:56 -04:00
daniel-c-harvey d9b92e0703 Merge branch 'seek-cancel-fix' into dev 2026-06-10 09:03:11 -04:00
daniel-c-harvey 0fd1977353 fix: silence false error log when streaming is cancelled during seek 2026-06-10 09:01:59 -04:00
daniel-c-harvey 1071ba7374 docs: bring CONTEXT.md §4 current — Phase 4 complete, iframe player landed 2026-06-09 21:58:24 -04:00
daniel-c-harvey 79a015f60a docs: update CLAUDE.md files to reflect Range header seek, remove WavOffsetService references 2026-06-09 07:41:38 -04:00
daniel-c-harvey 0bd7e6904d Merge branch 'p4-w2-retire-offset' into dev 2026-06-09 07:37:51 -04:00
daniel-c-harvey f602eb9772 chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1) 2026-06-09 07:30:36 -04:00
daniel-c-harvey b372bee365 Merge branch 'stream-now-button' into dev 2026-06-09 07:25:25 -04:00
daniel-c-harvey fad3635fa1 Merge branch 'p4-w1-range-streaming' into dev 2026-06-09 07:19:26 -04:00
daniel-c-harvey 561f4a500a docs: close Phase 4.1 and 4.2 — move to COMPLETED.md 2026-06-09 07:07:57 -04:00
daniel-c-harvey 9be35e5a58 refactor: extract StreamNowButton component shared by hero and nav menu 2026-06-09 07:00:37 -04:00
daniel-c-harvey aaa9f732ae feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client
- API: enableRangeProcessing true on no-offset FileStream path
- Proxy: transparent Range relay, forwards 206/416/Content-Range verbatim
- TrackMediaClient: Range: bytes=X- replaces ?offset=X; response disposed via TrackMediaResponse
- StreamDecoder: reinitializeForRangeContinuation retains wavHeader, counts raw PCM against 206 Content-Length
- AudioPlayer: seekBeyondBuffer adds headerSize for file-absolute offset; duration guard prevents continuation overwriting full-track duration
- StreamingAudioPlayerService: seek guard corrected to >= 0 (file-absolute offset contract)
2026-06-09 07:00:35 -04:00
daniel-c-harvey 5c3c3c3d0c docs(plan): commit Phase 4.1 to Option A1 (Range headers, custom decoder)
Record the design-gate decision for HTTP Range support: Range headers in
the JS fetch retaining the AudioBuffer decoder, rejecting MediaElement
(loses early-playback) and synthesized-header-over-Range (breaks caching
invariant). Add per-file shape, acceptance criteria, and the file-absolute
offset constraint. Tighten 4.2 — disk-streaming already done on the
default path; only the legacy offset branch remains.
2026-06-09 06:33:29 -04:00
daniel-c-harvey 760e9a1982 fix: Adjust Spectrum Bar Colors
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m37s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m23s
2026-06-09 06:23:23 -04:00
daniel-c-harvey 5b3bbc7b47 Merge branch 'lmf-icon-56' into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m2s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m8s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m36s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-08 16:33:28 -04:00
daniel-c-harvey f40786171d fix: shrink .lmf-icon to 56px to match MudFab Size.Large 2026-06-08 16:33:20 -04:00
daniel-c-harvey cef1e6bc69 Merge branch 'lmf-big-note' into dev 2026-06-08 16:27:14 -04:00
daniel-c-harvey 5258729c86 feat: enlarge LevelMeterFab note to 68px so it fills the 72px FAB 2026-06-08 16:27:08 -04:00
daniel-c-harvey 8679a9f619 fix: scale LevelMeterFab music note to fill the FAB — bump .lmf-icon from 24px to 56px 2026-06-08 16:17:23 -04:00
daniel-c-harvey ae22153edb style: LevelMeterFab FAB to 72px, icon to 36px 2026-06-08 16:16:22 -04:00
daniel-c-harvey e3df6dd93e fix: scale LevelMeterFab music note to fill the FAB — bump .lmf-icon from 24px to 56px 2026-06-08 16:15:01 -04:00
daniel-c-harvey 6151e6024c Merge branch 'gradient-tune' into dev 2026-06-08 14:55:07 -04:00
daniel-c-harvey 505ac0c47b style: retune spectrum gradient — dark green floor 0-30%, expand yellow/orange zones 2026-06-08 14:54:56 -04:00
daniel-c-harvey 6cacf51318 Merge branch 'gallery-card-border' into dev 2026-06-08 14:53:33 -04:00
daniel-c-harvey 87971dbd6f style: revert fallback thumb background to deepdrft-navy-mid 2026-06-08 14:53:09 -04:00
daniel-c-harvey 881d3d49cd style: thicken track card border to 2px solid secondary palette color 2026-06-08 14:52:01 -04:00
daniel-c-harvey 561cd45237 Merge branch 'spectrum-gradient' into dev 2026-06-08 14:49:30 -04:00
daniel-c-harvey 4e6e3c9eab feat: apply amplitude-tracking gradient to spectrum bars matching LevelMeterFab color scheme 2026-06-08 14:49:23 -04:00
daniel-c-harvey 4ab48ce527 Merge branch 'level-rms' into dev 2026-06-08 14:41:29 -04:00
daniel-c-harvey 58725c4646 feat: true RMS dBFS level measurement for LevelMeterFab via getFloatTimeDomainData 2026-06-08 14:40:11 -04:00
daniel-c-harvey 9cbc09edf7 Merge branch 'level-meter-tune' into dev 2026-06-08 14:20:57 -04:00
daniel-c-harvey 149127c920 fix: recalibrate level meter dB window to [-70, -10] for FFT peak data 2026-06-08 14:20:50 -04:00
daniel-c-harvey ad1c85f3ee Merge branch 'p2-w1-interactivity-guards' into dev 2026-06-08 14:14:02 -04:00
daniel-c-harvey 095b49701f docs: move PLAN 2.4 to COMPLETED — interactivity-gap loading guards landed 2026-06-08 14:11:42 -04:00
daniel-c-harvey 0392ef6954 Merge branch 'level-meter-fill' into dev 2026-06-08 13:31:58 -04:00
daniel-c-harvey c086d03776 feat: guard interactivity-gap controls until WASM hydrates (PLAN 2.4) 2026-06-08 13:31:54 -04:00
daniel-c-harvey b9969640e5 feat: continuous vertical VU fill for LevelMeterFab, replacing 3-band tint 2026-06-08 08:55:45 -04:00
daniel-c-harvey a2814fc939 docs(plan): add 2.4 interactivity-gap loading guard for dead-during-prerender controls 2026-06-08 08:44:41 -04:00
daniel-c-harvey 5b50879476 docs: spec level-meter fill animation (continuous VU-style note fill) 2026-06-08 08:40:03 -04:00
daniel-c-harvey 16f4f894f9 Merge branch 'gallery-text-fix' into dev 2026-06-08 08:38:01 -04:00
daniel-c-harvey 2bac1520db fix: readable text in list mode light theme — override hard-coded off-white with mud-palette-text-primary inside .deepdrft-track-row 2026-06-08 08:36:45 -04:00
daniel-c-harvey 6ce7c580a0 Merge branch 'level-meter-css-fix' into dev 2026-06-08 08:31:41 -04:00
daniel-c-harvey 1c942ffb2b fix: LevelMeterFab icon tint via inline style, bypass Blazor CSS isolation scoping of :root 2026-06-08 08:25:56 -04:00
daniel-c-harvey b88af29731 Merge branch 'gallery-polish' into dev 2026-06-08 08:12:28 -04:00
daniel-c-harvey 21e1a33ccf style: semi-transparent hover overlay and theme-aware list row background in TrackCard 2026-06-08 08:12:04 -04:00
daniel-c-harvey 2db9a6251a docs: record Track Gallery View Toggle landing in COMPLETED.md 2026-06-08 08:05:03 -04:00
daniel-c-harvey 00a3cc8034 Merge branch 'embed-transparent-bg' into dev 2026-06-08 08:02:37 -04:00
daniel-c-harvey 6705c52b69 Merge branch 'gallery-view-toggle' into dev 2026-06-08 08:02:13 -04:00
daniel-c-harvey 4e6cda939d fix(embed): transparent background via dedicated Embed theme instead of inline CSS variable override 2026-06-08 08:00:48 -04:00
daniel-c-harvey 1bd27f2160 fix: add ::deep to track-row-fab rule and define deepdrft-track-row--playing style 2026-06-08 07:59:28 -04:00
daniel-c-harvey 8fbabcdbc5 feat: add grid/list view toggle to track gallery with hover-reveal art cards 2026-06-08 07:56:14 -04:00
daniel-c-harvey 1fdffb1e50 Merge branch 'level-meter-fab-fix' into dev 2026-06-08 07:52:46 -04:00
daniel-c-harvey 2eebc04733 docs: spec Track Gallery View Toggle (grid hover-reveal + list mode) in PLAN.md 2026-06-08 07:49:42 -04:00
daniel-c-harvey 7eae599490 fix(LevelMeterFab): replace MudFab with hand-rolled button+SVG so band color tinting is no longer overridden by MudBlazor internals 2026-06-08 07:46:49 -04:00
daniel-c-harvey 9169493d41 Merge branch 'level-meter-fab' into dev 2026-06-08 07:22:51 -04:00
daniel-c-harvey f1da2382d2 docs: record LevelMeterFab landing in COMPLETED.md and update CLAUDE.md 2026-06-08 07:21:12 -04:00
daniel-c-harvey 165d935ae7 feat: LevelMeterFab tints the minimized-dock FAB icon by live audio level 2026-06-08 07:15:57 -04:00
daniel-c-harvey cef4d243f3 docs: record album art cover wiring in COMPLETED.md 2026-06-08 07:15:27 -04:00
daniel-c-harvey d07ebc9e66 Merge branch 'album-art-detail' into dev 2026-06-08 07:13:03 -04:00
daniel-c-harvey 317e9f84b8 Merge branch 'stream-now-loading-fix' into dev 2026-06-08 07:11:13 -04:00
daniel-c-harvey c57e61f7f9 fix: decouple Stream Now label flag from re-entrancy guard 2026-06-08 07:09:54 -04:00
daniel-c-harvey 2e165d0aef feat: render album art in track detail cover slot, falling back to gradient placeholder 2026-06-08 07:09:39 -04:00
daniel-c-harvey b7b539743b docs: add LevelMeterFab product spec for minimized-dock level meter 2026-06-08 06:59:03 -04:00
daniel-c-harvey 0e5cf7e79d fix: clear stream-loading state before SelectTrackStreaming 2026-06-08 06:54:48 -04:00
daniel-c-harvey 3f02686012 docs: move Phase 2.5 Stream Now to COMPLETED.md 2026-06-07 18:39:49 -04:00
daniel-c-harvey 9015411f12 Merge branch 'p2-w5-stream-now' into dev 2026-06-07 18:35:37 -04:00
daniel-c-harvey 0d4ef369b9 feat: Stream Now instant-play of a random track from the nav button 2026-06-07 18:33:08 -04:00
daniel-c-harvey 4b1a68aa29 docs: close §2.5 open question — add GET api/track/random endpoint 2026-06-07 17:21:50 -04:00
daniel-c-harvey ea535e0c7e Merge branch 'frame-player-cors' into dev 2026-06-07 17:19:38 -04:00
daniel-c-harvey ceb0984262 fix: force FramePlayer to WASM-only render mode; document CORS policy intent 2026-06-07 17:16:49 -04:00
daniel-c-harvey 94a2789127 Merge branch 'seek-state-fix' into dev 2026-06-07 17:15:45 -04:00
daniel-c-harvey 2b4cdeaf72 docs: spec Stream Now random-track instant-play feature (PLAN 2.5) 2026-06-07 16:56:56 -04:00
daniel-c-harvey 7cd85f0bb1 fix: convert absolute pause position to buffer-relative on resume after seek-beyond-buffer 2026-06-07 16:55:31 -04:00
daniel-c-harvey 465cb1ff6c feat: allow /FramePlayer to be embedded in external iframes via CORS + CSP frame-ancestors 2026-06-07 16:53:49 -04:00
daniel-c-harvey 40e001cc7a docs: move Phase 2.1 cover art to COMPLETED.md 2026-06-07 16:46:17 -04:00
daniel-c-harvey a6eba5d8c3 Merge branch 'p2-w2-t2-cms-image' into dev 2026-06-07 16:41:41 -04:00
daniel-c-harvey c766cdf5b8 Merge branch 'p2-w2-t1-public-image' into dev 2026-06-07 16:41:39 -04:00
daniel-c-harvey 905d7fa409 Merge branch 'share-button' into dev 2026-06-07 16:41:35 -04:00
daniel-c-harvey c4dc382bd7 fix: client-side image type guard and deselect affordance on TrackEdit 2026-06-07 16:41:02 -04:00
daniel-c-harvey fa28bfb5cc feat: add Share popover to track detail page 2026-06-07 16:38:37 -04:00
daniel-c-harvey 5703ac2752 feat: CMS cover-art upload on track edit page 2026-06-07 16:33:53 -04:00
daniel-c-harvey 10cb96ef7c feat: add public image proxy and wire TrackCard cover art to api/image/{entryKey} 2026-06-07 16:33:24 -04:00
daniel-c-harvey f6616ed109 Merge branch 'p2-w1-cover-art-api' into dev 2026-06-07 16:27:42 -04:00
daniel-c-harvey 6ef88bef38 docs: document SetMinimized as single mutation point in AudioPlayerBar 2026-06-07 16:20:58 -04:00
daniel-c-harvey 7bd9a434ca Merge branch 'player-minimize-sync' into dev 2026-06-07 16:16:44 -04:00
daniel-c-harvey 627d5623f0 feat: image vault + cover-art API (upload/serve endpoints, ImagePath metadata link) 2026-06-07 16:16:38 -04:00
daniel-c-harvey 1e9313a5d7 docs: move iframe player and backward seek to COMPLETED.md 2026-06-07 16:15:30 -04:00
daniel-c-harvey 5bc1b63b61 fix: route all _isMinimized mutations through SetMinimized so spacer stays in sync
Expand, ToggleMinimized, and Close now share one guarded mutator that fires
OnMinimized and renders. Fixed prerender branch left as a direct assignment.
2026-06-07 16:14:55 -04:00
daniel-c-harvey 9ead3bf2a7 docs: add player minimize/spacer sync design brief 2026-06-07 15:24:19 -04:00
daniel-c-harvey eecab12f48 Merge branch 'wav-duration-fix' into dev 2026-06-07 15:10:58 -04:00
daniel-c-harvey 858110306c fix: preserve full-track duration after seek-beyond-buffer reinit 2026-06-07 15:09:48 -04:00
daniel-c-harvey 4e6ec75000 Merge branch 'seek-fix' into dev 2026-06-07 15:07:13 -04:00
daniel-c-harvey 8e4d783ec2 chore: Move TrackCard & Friends 2026-06-07 15:06:58 -04:00
daniel-c-harvey daa334a947 fix: seek lower-bound guard and pointer-down callback ordering
AudioPlayer.ts: route seeks below bufferStart to seekBeyondBuffer;
previous missing lower-bound caused clamped playback after first seek.
WaveformSeeker: fire OnSeekStart/OnSeekChange before capturePointer
await to prevent fast-click race that locked _isSeeking true.
Latent: WavOffsetService encodes remaining-only DataSize, overwriting
JS this.duration after seek — not fixed here, scope separately.
2026-06-07 15:02:34 -04:00
daniel-c-harvey bd15b66aee feature: Home Page & Footer Mobile Friendly
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m56s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m3s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m22s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m27s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-07 13:48:12 -04:00
daniel-c-harvey 4072197313 Merge branch 'hero-animation-seam' into dev 2026-06-07 13:05:33 -04:00
daniel-c-harvey 22452815c6 docs: mark WASM seam G1/R1 resolved in audit doc 2026-06-07 13:02:21 -04:00
daniel-c-harvey 8ba3a10e15 fix: gate hero fade-up on SSR pass only to stop double-fire on WASM hydration 2026-06-07 12:57:54 -04:00
daniel-c-harvey ba31e124f2 docs: WASM SSR-handoff seam audit and remediation plan 2026-06-07 10:09:40 -04:00
daniel-c-harvey 86d70c1af6 Merge branch 'hero-xs' into dev 2026-06-07 10:06:23 -04:00
daniel-c-harvey e04f780014 fix: stack hero-actions buttons full-width at xs (<=599px) 2026-06-07 10:06:20 -04:00
daniel-c-harvey 80a79c1232 Merge branch 'xs-responsive' into dev 2026-06-07 10:01:06 -04:00
daniel-c-harvey 75766154bb fix: correct xs breakpoint from 600px to 599px in Home.razor.css (sm starts at 600px) 2026-06-07 09:50:09 -04:00
daniel-c-harvey cb9c5f9b3c fix: add trailing newline to DeepDrftFooter.razor.css 2026-06-07 09:45:38 -04:00
daniel-c-harvey 5d3ea49de8 fix: stack NowPlayingStats vertically and tighten footer padding at xs (<=599px) 2026-06-07 09:43:51 -04:00
daniel-c-harvey a2b8b12bf0 Merge branch 'p1-w1-original-filename' into dev 2026-06-07 09:03:13 -04:00
daniel-c-harvey fcaf8f0bf6 Merge branch 'waveform-fixes' into dev 2026-06-07 09:00:50 -04:00
daniel-c-harvey 3de88c786a feat: capture and display original upload filename for tracks 2026-06-07 09:00:17 -04:00
daniel-c-harvey 5cdd69d7d9 fix: WaveformSeeker resize drift and mobile fast-tap crash
- Add ResizeObserver (JS observeResize/unobserveResize + C# OnWidthChanged)
  so _elementWidth stays current after window resize, fixing hover indicator drift
- Move _isSeeking = true before capturePointer await so a fast mobile tap
  that fires pointerup mid-await still commits the seek
- Replace all Duration!.Value null-forgiving dereferences with explicit
  Duration is > 0 guards in all four pointer event handlers
- Silence post-dispose resize callback rejections with .catch(() => {})
2026-06-07 09:00:10 -04:00
daniel-c-harvey 6dfb3a2f23 fix: AudioPlayerBar Styles
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m10s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m31s
Deploy DeepDrftManager / Deploy (push) Successful in 1m24s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-07 08:19:52 -04:00
daniel-c-harvey 54939721e4 docs: move Phase 6 responsive home page from PLAN.md to COMPLETED.md 2026-06-07 07:56:22 -04:00
daniel-c-harvey ec88759b55 Merge branch 'p6-w1-home-mobile' into dev 2026-06-07 07:53:32 -04:00
daniel-c-harvey 8b3e7e0620 fix: wrap hero and section-split MudGrids in plain HTML elements so CSS isolation scope attributes reach .hero and .section-split rules 2026-06-07 07:48:26 -04:00
daniel-c-harvey 18b5fa9401 feature: Responsive mobile layout for home page
Migrate hero, section-header, and section-split to MudGrid with xs/sm/md breakpoints (Spacing=0 to keep color panels flush); add @media collapse rules for genre/features card grids and the CTA banner. Visual styling unchanged at desktop width.
2026-06-07 07:37:09 -04:00
daniel-c-harvey c4e7b49776 plan: add Phase 6 responsive home page (mobile layout) 2026-06-07 07:27:43 -04:00
daniel-c-harvey 13adb144a6 feature: Mobile Menu & Style Polish 2026-06-07 06:53:21 -04:00
daniel-c-harvey 84a302ce24 feature: Palette Enhancements 2026-06-06 21:24:19 -04:00
daniel-c-harvey 47d0475d3f Merge branch 'palette-light-shift' into dev 2026-06-06 20:49:18 -04:00
daniel-c-harvey 4341d97f12 theme: shift light palette primary to navy-mid, step green scale up one level 2026-06-06 20:49:14 -04:00
daniel-c-harvey bd110c07da Merge branch 'track-card-green-fix' into dev 2026-06-06 20:41:52 -04:00
daniel-c-harvey d1cb85b840 feat: adjust navy wireframe tokens and add green-interactive 2026-06-06 20:41:37 -04:00
daniel-c-harvey 07ba9946ce feat: add --deepdrft-green-interactive token to design token layer 2026-06-06 20:36:46 -04:00
daniel-c-harvey 4b5de088ab fix: correct MudBlazor Tertiary class targets and demote artist to muted off-white in TrackCard 2026-06-06 20:33:21 -04:00
daniel-c-harvey 9ce2631bf4 feature: AudioPlayer Enhancements
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m59s
Deploy DeepDrftManager / Build & Publish (push) Successful in 59s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m30s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m27s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-06 20:17:50 -04:00
daniel-c-harvey 475f93c8a3 feature: AudioPlayerBar Layout Enhancements 2026-06-06 19:47:17 -04:00
daniel-c-harvey a4b098b8ea feature: AudioPlayerBar enhancements 2026-06-06 17:48:07 -04:00
daniel-c-harvey 7dfdad2666 docs: archive track detail page to COMPLETED.md; update CLAUDE.md 2026-06-06 17:39:13 -04:00
daniel-c-harvey b1d58c1327 Merge branch 'track-detail-page' into dev 2026-06-06 17:30:10 -04:00
daniel-c-harvey 6b18d7cc1e Player Layout 2026-06-06 17:28:39 -04:00
daniel-c-harvey 93d9b47a67 fix: TrackDetail render mode, pause, and secondary text color 2026-06-06 16:45:07 -04:00
daniel-c-harvey 0dd33a5dfc Add track detail page with clickable cards 2026-06-06 16:33:57 -04:00
daniel-c-harvey 3e4ddbb2a6 docs: spec Track Detail page (/track/{entryKey}) in PLAN.md 2026-06-06 16:11:55 -04:00
daniel-c-harvey 1bb6e29e47 feature: Track Meta Labels on Player 2026-06-06 16:05:45 -04:00
daniel-c-harvey c83b132522 feature: Embed Frame Player 2026-06-06 15:43:09 -04:00
daniel-c-harvey d96c41eafb docs: reconcile PLAN.md and CONTEXT.md with post-split solution state 2026-06-06 15:27:14 -04:00
daniel-c-harvey 9110b4b764 docs: archive play-state icon normalization; update DeepDrftPublic.Client CLAUDE.md
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m57s
Deploy DeepDrftManager / Build & Publish (push) Successful in 59s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m34s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
Deploy DeepDrftManager / Deploy (push) Successful in 1m30s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m26s
2026-06-06 11:59:53 -04:00
daniel-c-harvey 526e607f33 Merge branch 'play-icons-w2-component' into dev 2026-06-06 11:52:57 -04:00
daniel-c-harvey 7d3da58573 Style Polish 2026-06-06 11:52:41 -04:00
daniel-c-harvey e3fe401abf Consolidate play/pause icon logic into PlaybackIcons mapper and PlayStateIcon component
Add Disabled parameter to PlayStateIcon; forward to MudIconButton;
pass Disabled="!IsLoaded" from PlayerControls to match Stop button parity.
2026-06-06 10:46:32 -04:00
daniel-c-harvey 1d97729e57 Merge branch 'play-icons-w1-gallery-fix' into dev 2026-06-06 10:09:11 -04:00
daniel-c-harvey 766e98fd2b Reflect real playback state on gallery cards and toggle pause/resume
Add IsPaused/OnPause to TrackCard, make TracksGallery controlled, and
drive the active track from PlayerService.CurrentTrack as the single
source of truth.
2026-06-06 10:09:07 -04:00
daniel-c-harvey d055c2a548 WASM State Fixes 2026-06-06 09:59:43 -04:00
daniel-c-harvey 75bf93c2bb CMS Home autoredirect to /tracks 2026-06-06 09:52:50 -04:00
daniel-c-harvey b746645f97 WaveformSeeker Improvements 2026-06-06 09:52:31 -04:00
daniel-c-harvey ab9db6d0ec Merge branch 'track-card-css-fix2' into dev 2026-06-05 20:48:27 -04:00
daniel-c-harvey 3dc9fc2446 fix(css): solid navy fallback, force green FAB+chip on dark card 2026-06-05 18:31:56 -04:00
daniel-c-harvey 59dbfb8aab docs: note preprocessing panel fold into TrackList tabs 2026-06-05 18:27:53 -04:00
daniel-c-harvey 76e16fe32e Merge branch 'merge-preprocessing-tab' into dev 2026-06-05 18:21:36 -04:00
daniel-c-harvey 97c8439ed7 Fold waveform preprocessing into tracks page as tab 2026-06-05 18:20:33 -04:00
daniel-c-harvey cabc8654d1 Merge branch 'waveform-w3-cms' into dev 2026-06-05 17:59:52 -04:00
daniel-c-harvey f468fafaba Merge branch 'track-card-css-scope' into dev 2026-06-05 17:57:13 -04:00
daniel-c-harvey af6ed6130f docs: log WaveformSeeker W3 completion in COMPLETED.md 2026-06-05 17:57:11 -04:00
daniel-c-harvey 6e25ad3085 Add CMS waveform pre-processing panel with backfill endpoints
GET api/track/waveform-status and POST api/track/{id}/waveform (ApiKey);
CmsTrackService methods; TrackPreProcessing page with per-row and
sequential bulk generation; nav links from TrackList and Index.
2026-06-05 17:56:25 -04:00
daniel-c-harvey 75db127708 docs: log track card CSS scoping in COMPLETED.md 2026-06-05 17:56:21 -04:00
daniel-c-harvey 84307dabde fix(css): ::deep track text color rules to pierce MudText 2026-06-05 17:41:56 -04:00
daniel-c-harvey 1b493434d6 Merge branch 'waveform-w2-seeker' into dev 2026-06-05 17:37:01 -04:00
daniel-c-harvey 2ee0667aa2 docs: log WaveformSeeker W2 completion in COMPLETED.md 2026-06-05 17:36:03 -04:00
daniel-c-harvey 9c916245c1 refactor(css): scope track card styles; apply NowPlayingCard color vocabulary 2026-06-05 17:35:16 -04:00
daniel-c-harvey 8de7342352 Replace MudSlider seekbar with WaveformSeeker loudness-waveform control
DOM bar chart with clip-overlay progress split; pointer-capture drag;
WaveformProfile fetched on load (fire-and-forget, cancellable); flat
fallback when no profile; small lazily-loaded waveformSeeker.js for
getBoundingClientRect and setPointerCapture.
2026-06-05 17:35:11 -04:00
daniel-c-harvey acd76e0601 docs: mark track-view CSS consolidation completed 2026-06-05 17:00:36 -04:00
daniel-c-harvey 7c89220667 Merge branch 'waveform-w1-t2-api' into dev 2026-06-05 16:58:59 -04:00
daniel-c-harvey 9cfcd5f67a docs: log WaveformSeeker W1-T2 completion in COMPLETED.md 2026-06-05 16:58:38 -04:00
daniel-c-harvey 9538310c43 Merge branch 'track-css-consolidation' into dev 2026-06-05 16:58:12 -04:00
daniel-c-harvey b3473aa37e refactor(css): consolidate track-view layout and card text color rules; switch genre chip to Outlined variant 2026-06-05 16:58:07 -04:00
daniel-c-harvey de4583b759 Add waveform profile HTTP transport: API endpoint, public proxy, content client method 2026-06-05 16:57:42 -04:00
daniel-c-harvey 9d39843982 Merge branch 'waveform-w1-t3-layout' into dev 2026-06-05 16:50:09 -04:00
daniel-c-harvey edf45bb8de Merge branch 'waveform-w1-t1-computation' into dev 2026-06-05 16:50:04 -04:00
daniel-c-harvey 9854d51940 docs(product): track-view CSS consolidation audit and spec 2026-06-05 16:43:19 -04:00
daniel-c-harvey 92f860897b docs: log WaveformSeeker W1-T1 and W1-T3 completions in COMPLETED.md 2026-06-05 16:40:22 -04:00
daniel-c-harvey cc1fa60a4d refactor(player): move SpectrumVisualizer into VolumeZone above volume slider
Rename VolumeControls to VolumeZone; stack 24-bucket SpectrumVisualizer above volume
slider; remove it from PlayerSeekZone. MudSlider stays as seek placeholder. Pin
flex-shrink:0 on volume-zone; add Class param to VolumeZone for layout flexibility.
2026-06-05 16:38:13 -04:00
daniel-c-harvey fa57861dbf Add server-side waveform loudness profiling on track upload
ILoudnessAlgorithm strategy (RmsLoudnessAlgorithm first impl), WaveformProfileService
stores quantized byte[] sidecar in new MediaFileVault (profiles vault), wired into
UnifiedTrackService.UploadAsync; failure is logged and swallowed. WaveformProfileDto
and WaveformProfileOptions in shared projects.
2026-06-05 16:38:02 -04:00
daniel-c-harvey 7c401d75b5 docs: mark track-card plain-shell refactor completed 2026-06-05 16:27:51 -04:00
daniel-c-harvey 3c17260f32 Merge branch 'track-card-plain-shell' into dev 2026-06-05 16:26:20 -04:00
daniel-c-harvey 61c5bee5d7 refactor(track-card): replace MudCard/MudPaper shells with plain divs, drop !important from section 8 backgrounds 2026-06-05 16:26:17 -04:00
daniel-c-harvey eed99df0dd Merge branch 'track-card-flash-fix' into dev 2026-06-05 16:15:31 -04:00
daniel-c-harvey 1986aed902 fix(css): eliminate track card flash — transparent container, stable fallback base color, unconditional text defaults 2026-06-05 16:15:27 -04:00
daniel-c-harvey c10d315a7b docs(product): add approved WaveformSeeker spec
Loudness-waveform seekbar replacing MudSlider; ILoudnessAlgorithm
abstraction (RMS first, LUFS future); vault sidecar storage; CMS
PreProcessing panel for backfill; VolumeZone rename. All decisions
resolved 2026-06-05.
2026-06-05 15:44:40 -04:00
daniel-c-harvey b9b2c131a8 docs: mark track-card glass theming completed 2026-06-05 15:36:40 -04:00
daniel-c-harvey 231ed399a3 Merge branch 'track-card-glass' into dev 2026-06-05 15:26:56 -04:00
daniel-c-harvey d9664988ad Player Bar Cosmetics 2026-06-05 15:26:49 -04:00
daniel-c-harvey b22b57069d style(track-card): glass theming — remove MudBlazor color overrides, add theme-scoped CSS for title/artist/meta hierarchy and navy-glass fallback panel 2026-06-05 15:18:56 -04:00
daniel-c-harvey a86ccae432 Merge branch 'playerbar-timestamp-move' into dev 2026-06-05 14:59:02 -04:00
daniel-c-harvey 87f722fa58 refactor(player): move TimestampLabel from PlayerTransportZone to PlayerSeekZone so volume centers against buttons row height 2026-06-05 14:38:38 -04:00
daniel-c-harvey 31d2c2ee7e Merge branch 'playerbar-layout-fix' into dev 2026-06-05 14:29:53 -04:00
daniel-c-harvey 78c6803e6b fix(css): halve volume control width and pin it to flex-start at wide breakpoints 2026-06-05 14:28:50 -04:00
daniel-c-harvey 8178174275 Merge branch 'audioplayer-unified' into dev 2026-06-05 14:15:07 -04:00
daniel-c-harvey ffb71b6d71 docs: move AudioPlayerBar unification from PLAN.md to COMPLETED.md 2026-06-05 14:14:45 -04:00
daniel-c-harvey cbc43300b2 fix(css): remove ::deep from PlayerTransportZone root-element selectors, replace dead controls-left rule 2026-06-05 14:08:16 -04:00
daniel-c-harvey 190d8d044f Unify AudioPlayerBar to one responsive CSS layout and fix SpectrumVisualizer startup via StateChanged subscription 2026-06-05 14:04:31 -04:00
daniel-c-harvey 4887454911 docs(plan): add AudioPlayerBar responsive unification proposal 2026-06-05 13:52:52 -04:00
daniel-c-harvey 0c5ebae9c9 chore: move SpectrumVisualizer above seek slider in PlayerSeekZone 2026-06-05 13:52:46 -04:00
daniel-c-harvey 91214336c5 chore: move spectrum visualizer above seek slider; fix controls-left CSS scoping 2026-06-05 13:52:05 -04:00
daniel-c-harvey 4616fbf0e1 Merge branch 'mobile-seek-dry' into dev 2026-06-04 20:31:22 -04:00
daniel-c-harvey 72e9f71fbc Refactor mobile AudioPlayerBar seek to use PlayerSeekZone, removing inline duplicate gesture code 2026-06-04 20:12:57 -04:00
daniel-c-harvey b6572bead0 chore: set Microsoft.AspNetCore log level to Warning 2026-06-04 20:08:28 -04:00
daniel-c-harvey f07ab4b235 fix(css): add ::deep prefix to MudBlazor component classes in AudioPlayerBar scoped styles 2026-06-04 20:04:27 -04:00
daniel-c-harvey 73e0eea328 Merge branch 'seek-pointerleave-fix' into dev 2026-06-04 19:55:08 -04:00
daniel-c-harvey dbf02a9426 fix(seek): guard HandlePointerLeave with _isSeeking to prevent spurious seek-to-zero on mouse-out 2026-06-04 19:53:22 -04:00
daniel-c-harvey b24c6ff78e Merge branch 'player-desktop-redesign' into dev 2026-06-04 19:34:27 -04:00
daniel-c-harvey de0c01ef4d docs: record desktop AudioPlayerBar MudBlazor theme migration 2026-06-04 19:32:27 -04:00
daniel-c-harvey 8420ab8d37 Migrate desktop AudioPlayerBar to MudBlazor theme surface 2026-06-04 19:28:14 -04:00
daniel-c-harvey a57e0f71c4 docs(product): add AudioPlayerBar desktop redesign proposal 2026-06-04 18:49:23 -04:00
daniel-c-harvey 7622e94ba2 Merge branch 'remove-audio-debug-logs' into dev 2026-06-04 18:46:22 -04:00
daniel-c-harvey 034e9d5633 chore: remove debug console.log calls from audio TS interop 2026-06-04 18:40:45 -04:00
daniel-c-harvey db8a44fc79 Home Page Style Normalization Fixes (Animations) 2026-06-04 18:23:59 -04:00
daniel-c-harvey 6e274b7395 Merge branch 'focus-ring-fix' into dev 2026-06-04 18:22:31 -04:00
daniel-c-harvey 21b7661ca8 fix: suppress h1 focus ring caused by FocusOnNavigate in both Blazor apps 2026-06-04 18:18:23 -04:00
daniel-c-harvey 79591fe4e4 Merge branch 'ci-setup-node' into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m16s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m23s
2026-06-04 17:47:12 -04:00
daniel-c-harvey c69c25c6dc ci: add setup-node@v4 to deploy-public build job
Microsoft.TypeScript.MSBuild requires node on PATH during dotnet publish.
Without an explicit setup step, TS compilation silently skips on runners
that don't pre-install Node, leaving wwwroot/js/audio/ empty in the artifact.
2026-06-04 17:46:47 -04:00
daniel-c-harvey 4171b493fd Merge branch 'public-static-fix' into dev 2026-06-04 17:45:13 -04:00
daniel-c-harvey fe8ddff41c docs: document request pipeline and UseStaticFiles/MapStaticAssets relationship in DeepDrftPublic 2026-06-04 17:43:46 -04:00
daniel-c-harvey 58a94fe315 docs: explain why UseStaticFiles is not redundant with MapStaticAssets 2026-06-04 17:42:15 -04:00
daniel-c-harvey 757c1d5c85 fix: add UseStaticFiles() after UseAntiforgery() so JS audio module is served with correct Content-Type in production 2026-06-04 17:40:10 -04:00
daniel-c-harvey 194a76ce4c Workflow Build Trigger
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m13s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m6s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m23s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m26s
2026-06-04 14:32:20 -04:00
daniel-c-harvey a34e083c2e Merge branch 'drop-unit-rsync' into dev
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 3m13s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m32s
2026-06-04 14:23:24 -04:00
daniel-c-harvey 52d6afa335 ci: stop shipping unit file in deploy — unit is host config, not CI artifact 2026-06-04 14:23:21 -04:00
daniel-c-harvey ceaa684c74 Merge branch 'factory-fix' into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m28s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m35s
2026-06-04 14:16:35 -04:00
daniel-c-harvey cd226f3ce9 fix: factory falls back to design-time dummy; remove CI dummy-file step and creds-env cp lines 2026-06-04 14:16:31 -04:00
239 changed files with 18274 additions and 2074 deletions
-10
View File
@@ -9,7 +9,6 @@ on:
- 'DeepDrftContent/**'
- 'DeepDrftModels/**'
- '.gitea/workflows/deploy-api.yml'
- 'deploy/systemd/deepdrftapi.service'
jobs:
build:
@@ -45,14 +44,6 @@ jobs:
--no-build \
-o DeepDrftAPI/publish
# DeepDrftContextFactory reads environment/connections.json at design time.
# Write a parseable dummy so the factory does not throw during bundle construction.
# The bundle only needs the provider type, not a live database connection.
- name: Write dummy connections file for EF bundle
run: |
mkdir -p DeepDrftAPI/environment
echo '{"ConnectionStrings":{"DefaultConnection":"Host=localhost;Database=dummy;Username=dummy","Auth":"Host=localhost;Database=dummy;Username=dummy"}}' > DeepDrftAPI/environment/connections.json
# EF bundle: self-contained binary that applies DeepDrftContext migrations on the host
# without the .NET SDK. AuthBlocks' Identity DB is NOT covered here — it self-migrates
# via UseAuthBlocksStartupAsync() on first boot.
@@ -118,7 +109,6 @@ jobs:
rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \
staging/deepdrft-api.tar.gz \
staging/deepdrft-migrations-bundle \
deploy/systemd/deepdrftapi.service \
deepdrft@$DEPLOY_HOST:
- name: Trigger deploy on host
+4
View File
@@ -21,6 +21,10 @@ jobs:
with:
dotnet-version: '10.0.x'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install wasm-tools workload
run: dotnet workload install wasm-tools
+4 -1
View File
@@ -311,4 +311,7 @@ __pycache__/
Database/Vaults/*
# TypeScript output
**/wwwroot/js/*
**/wwwroot/js/*
# ...except hand-authored client JS modules (not TS compile output).
!DeepDrftPublic.Client/wwwroot/js/
!DeepDrftPublic.Client/wwwroot/js/*.js
+2 -2
View File
@@ -14,7 +14,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
@@ -70,7 +70,7 @@ The player is not fetch-then-play:
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.
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}?offset={byteOffset}`. Server's `WavOffsetService` block-aligns offset, synthesises a fresh 44-byte WAV header, streams `[new header][data from offset]`. Player tears down and re-initialises decoder for the new stream.
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.
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
+1211
View File
File diff suppressed because it is too large Load Diff
+74 -50
View File
@@ -2,61 +2,77 @@
Living orientation doc for what this repo is, how it is currently shaped, and where it appears headed. Sits alongside the root `CLAUDE.md` (operational guidance) — this file is the product/architecture view.
> **Drift notice.** The root `CLAUDE.md` and every folder-level `CLAUDE.md` currently in the tree describe the project as `.NET 9`. The most recent commit upgraded all projects to `.NET 10` (every `.csproj` now targets `net10.0`, packages pinned at `10.0.1`). Until those docs are refreshed, treat any framework-version claim in them as stale. The other staleness items are listed at the bottom of this file.
> **Status.** The root `CLAUDE.md` is current — it reflects the post-split ten-project solution, `net10.0`, and the dual-app topology. This file (`CONTEXT.md`) was the lagging document and §2 / §4 / §7 below have been brought back into line with the root `CLAUDE.md` as of 2026-06-06. Folder-level `CLAUDE.md` files are still being swept (`DOC_PLAN.md`); treat framework-version and structural claims in any *folder* `CLAUDE.md` not yet rewritten as potentially stale until that sweep lands.
---
## 1. What this project is
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftWeb.Client/Pages/Home.razor`). The product is, at minimum:
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftPublic.Client/Pages/Home.razor`). The product is, at minimum:
- A public-facing site (hero, about, "experience" features).
- A public-facing site (hero, about, "experience" features) at `DeepDrftPublic`.
- A **track gallery** that browses a library of WAV recordings, plays them in-browser with a persistent dock-style player, and supports seek (including seek beyond what's been streamed so far).
- An admin CLI for adding tracks (Terminal.Gui or scripted), running locally against the same dual-database substrate the site uses.
- A browser-based **CMS** (`DeepDrftManager`) for adding, editing, and deleting tracks — gated behind AuthBlocks login and the `Admin` role. This replaced the former `DeepDrftCli` Terminal.Gui admin tool, which has been retired.
The interesting engineering bet is the **dual-database split**: structured track metadata in SQLite via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced across two ASP.NET Core hosts so that the browser never reaches the database directly.
The interesting engineering bet is the **dual-database split**: structured track metadata in PostgreSQL via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced through a dedicated authority host (`DeepDrftAPI`) so that the browser never reaches the database directly.
---
## 2. Solution shape (current)
Eight projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`.
Ten projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`. The solution is split into **two independent Blazor applications** — the public site (`DeepDrftPublic`) and the CMS (`DeepDrftManager`) — both fronting a single dual-database authority host (`DeepDrftAPI`).
```
DeepDrftWeb ASP.NET Core host. Blazor Web App (Server + WASM render modes).
Owns the SQL-backed API (api/track/page), MudBlazor theme/host,
TypeScript→JS audio interop sources under Interop/.
DeepDrftWeb.Client Blazor WebAssembly assembly. All interactive UI lives here —
pages, controls, player services, dark-mode/theme plumbing,
HTTP clients for both backends.
DeepDrftWeb.Services Class library. EF Core: DeepDrftContext, TrackConfiguration,
Migrations, TrackRepository, TrackService. Sharable between
the web host and the CLI (avoids duplicating data-access).
── Public application ──────────────────────────────────────────────────────
DeepDrftPublic ASP.NET Core host. Blazor Web App (Server + WASM render
modes). Owns the browser-facing proxy controller for
api/track/* (metadata listing + audio streaming),
MudBlazor theme prerender, and TypeScript→JS audio interop
sources under Interop/. The public listening surface.
DeepDrftPublic.Client Blazor WebAssembly assembly. All interactive public UI —
pages, the player stack, dark-mode plumbing, HTTP clients
for the backend. Consumed by DeepDrftPublic.
DeepDrftContent ASP.NET Core host. Binary content API (api/track/{id}).
ApiKey middleware, CORS, ForwardedHeaders. Returns audio bytes
(with optional byte offset) and accepts PUT of AudioBinaryDto.
DeepDrftContent.Services Class library. The FileDatabase implementation in full
── CMS application ─────────────────────────────────────────────────────────
DeepDrftManager ASP.NET Core host. Blazor Web App (InteractiveServer).
Hosts all CMS Razor components/pages (Components/Pages/Cms/,
Components/Pages/Tracks/, Components/Layout/CmsLayout.razor,
Components/Shared/ — inlined from the former DeepDrftCms RCL).
Gated by AuthBlocks login + hierarchical Admin role. All track
operations proxy via ICmsTrackService / CmsTrackService.
── Dual-database authority ─────────────────────────────────────────────────
DeepDrftAPI ASP.NET Core host. The single authority over both databases
(SQL metadata + FileDatabase binary). AuthBlocks API host
(registration, migration/seed, JWT endpoints). Seven track
endpoints (stream, vault write, upload, delete, paged list,
single metadata read, metadata update).
DeepDrftData Class library. EF Core domain logic: DeepDrftContext,
TrackConfiguration, Migrations, TrackRepository, TrackService,
TrackManager. Consumed by DeepDrftAPI and tests.
DeepDrftContent Class library. The FileDatabase implementation in full
(Models, Services, Utils, Abstractions, Constants),
WavOffsetService, AudioProcessor, TrackService (the content-side
orchestrator that processes WAVs and stores them in a vault).
WavOffsetService, AudioProcessor, content-side TrackService.
Consumed by hosts and tests.
── Shared ──────────────────────────────────────────────────────────────────
DeepDrftShared.Client Razor Class Library. Shared Blazor components consumed by
BOTH DeepDrftPublic and DeepDrftManager (e.g. TrackCard,
TracksGallery) for consistency across public and admin surfaces.
DeepDrftModels Shared contracts: TrackEntity, TrackDto, PagingParameters<T>,
PagedResult<T>. The only project all three layers reference.
DeepDrftCli Console app. Two modes: classic `add` / `list` / `help` and
`gui` (Terminal.Gui). Consumes BOTH service libraries directly
(it's a local admin tool, not a network client).
PagedResult<T>, plus waveform DTOs. Every project references this.
DeepDrftTests NUnit. Covers the FileDatabase, MediaVault, IndexSystem,
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, and
model behaviour. References DeepDrftContent.Services.
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, model
behaviour, and the waveform loudness algorithm. References
DeepDrftContent.
NetBlocks (external) Result patterns: Result, ResultContainer<T>, ApiResult<T>,
ApiResultDto<T>. Referenced via absolute path.
```
Two stray .sln files (`WebAPI.sln`, `WebUI.sln`, `CLI.sln`) exist at the root alongside `DeepDrftHome.sln`. `DeepDrftHome.sln` is the canonical solution; the others appear to be subsets.
**Naming history (for readers of older docs/commits):** `DeepDrftWeb``DeepDrftPublic`, `DeepDrftWeb.Client``DeepDrftPublic.Client`, `DeepDrftWeb.Services``DeepDrftData`, `DeepDrftContent.Services``DeepDrftContent` (the host that previously owned the binary API is gone; its proxy duties moved into `DeepDrftPublic`, its authority duties into `DeepDrftAPI`). `DeepDrftCli` and the `DeepDrftCms` RCL have both been removed — the CLI retired in favour of the CMS, and the CMS RCL was inlined into `DeepDrftManager`.
**Subdomain topology (deployment):** `deepdrft.com` (public) and `manage.deepdrft.com` (CMS), behind nginx. CD infrastructure (Gitea workflows + installer scripts + systemd/nginx templates) has landed — see `COMPLETED.md` "Deployment Infrastructure."
---
@@ -147,17 +163,19 @@ In dev, the host serves the original `.ts` sources at `/Interop/...` for source-
Recent commits (newest first):
- `style simplification and publish upgrades for dotnet 10`
- `Styles & Home Page Content Cleanup Mobile Menu System & Dark Mode Cookie Theme Draft`
- `Theming Draft 2`
- `2026 Deep DRFT Theme Draft 1 WIP`
- `Spectrum Visualizer for player & Layout`
- `docs: update CLAUDE.md files to reflect Range header seek, remove WavOffsetService references`
- `chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1)`
- `feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client`
- `refactor: extract StreamNowButton component shared by hero and nav menu`
- (earlier: WaveformSeeker improvements, play-state icon normalization, CMS build-out, the two-app split, deployment infrastructure)
Three observations:
Observations:
1. **The current arc is presentation, not capability.** The last five commits are framework upgrade, theming, content/layout cleanup, mobile menu, dark-mode persistence, and the spectrum visualiser. The playback substrate, streaming, and seek-beyond-buffer machinery landed earlier and is stable enough to support cosmetic iteration on top.
2. **The "Track Gallery" is the only real page.** `/tracks` is the working surface; `/` is marketing copy. Nav (in `Pages.cs`) defines only `Home` + `Track Gallery`.
3. **Content surface is narrow on purpose.** The DeepDrftContent API exposes exactly two routes: `GET api/track/{id}` (with optional `offset`) and `PUT api/track/{id}` (ApiKey). There is no listing endpoint there; listing lives on DeepDrftWeb because listings are SQL queries.
1. **The big structural moves have landed.** Since the last revision of this doc, three large initiatives shipped: the **two-app split** (public/CMS separation with `DeepDrftAPI` as the dual-database authority), the **browser CMS** replacing the CLI (auth via AuthBlocks, stealth-routed `/cms/*`, full add/list/edit/delete parity), and **CD infrastructure** (Gitea workflows + host installer + systemd/nginx templates). The substrate is no longer the frontier — the product and presentation layers are.
2. **Phase 4 streaming work is complete.** HTTP Range header seek (`Range: bytes=X-`) is now the sole seek mechanism; `WavOffsetService` and the `?offset=` path have been removed (Phase 4.1, merged 2026-06-09). `StreamDecoder.reinitializeForRangeContinuation` handles Range continuations by retaining the parsed WAV header. The streaming substrate is solid.
3. **The embeddable iframe player has landed** (commit `c83b132`, 2026-06-07). The presentation layer now includes a chrome-free single-track embed surface for off-site use, completing the Phase 4 feature set.
4. **The "Track Gallery" is still the only real public content page.** `/tracks` is the working listening surface; `/` is the (reskinned) marketing home. Nav (in `Layout/Pages.cs`) is still essentially `Home` + `Track Gallery`. The CMS adds admin surfaces under `/cms` but those are not public.
5. **The metadata/streaming surface is consolidated on `DeepDrftAPI`.** It exposes seven track endpoints (stream, vault write, upload, delete, paged list, single-metadata read, metadata update) plus waveform endpoints. `DeepDrftPublic` is a thin browser-facing proxy in front of it; the browser never reaches `DeepDrftAPI` or the databases directly.
---
@@ -167,7 +185,7 @@ Captured here so the next round of planning has a starting point — none of thi
- **More vault types in active use.** `MediaVaultType.Image` exists end-to-end (tests cover it) but the production surface only registers a `tracks` vault of type `Audio`. The path to releases/albums probably runs through images first (cover art via `ImagePath`, which is currently a free-form URL string).
- **More than one collection view.** The `TrackCard` already conditionally renders `ImagePath`, `Album`, `Genre`, `ReleaseDate` — the data shape supports album-grouped or genre-filtered views without schema work.
- **Upload from the web side, not just the CLI.** The CLI is currently the only producer of tracks. A web-side upload would re-use `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync` and pair it with a `TrackService.Create` on the SQL side. The `[ApiKeyAuthorize]` middleware on `PUT api/track/{id}` is already in place.
- **Web upload — landed.** *(Historical note: this was a "likely direction" when the CLI was the only producer. It has since shipped.)* The CMS (`DeepDrftManager`) now produces tracks via `POST api/track/upload` on `DeepDrftAPI`, proxied through the auth-gated CMS surface. The CLI has been retired. The dual-write rollback gap (`PLAN.md §4.3`) still stands.
- **Live/session content.** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these yet; they would likely need new vault types (`MediaVaultType.Media` is the obvious home for video) and new entity tables.
- **Non-WAV formats.** Today the producer side is WAV-only (`AudioProcessor.ProcessWavFileAsync` validates RIFF/WAVE/PCM). `MimeTypeExtensions` already knows mp3/flac/aac/ogg/m4a — the gap is a processor per format and a decoder strategy in the JS player (currently WAV-specific).
- **Search / filter on the gallery.** `TracksViewModel` exposes `SortBy` / `IsDescending` but no filter. `TrackService.GetPaged` accepts only sort, not filter. Adding filter would be a natural next step on the same pagination contract.
@@ -187,14 +205,20 @@ Captured here so the next round of planning has a starting point — none of thi
## 7. Staleness in existing docs (for doc-keeper to address)
Captured so the next sweep of folder-level `CLAUDE.md` files can correct in one pass.
Two layers of drift remain. The root `CLAUDE.md` and this `CONTEXT.md` are current; the lag is now in **folder-level `CLAUDE.md` files** and the in-tree `FileDatabase` README. `DOC_PLAN.md` holds the per-folder rewrite briefs, but note that `DOC_PLAN.md` itself was authored against the *pre-split* project names (2026-05-16) and is partly superseded — see the warning at the end of this section.
- Every folder `CLAUDE.md` says ".NET 9" / "ASP.NET Core 9.0"; reality is `net10.0` across the board.
- `DeepDrftModels/CLAUDE.md` and `DeepDrftContent.Services/FileDatabase/README.md` reference `TrackEntity.MediaPath`; the field is `EntryKey` and the column is `entry_key`.
- `DeepDrftContent/CLAUDE.md` describes a `FileDatabase/` tree inside `DeepDrftContent/`; that tree has moved entirely to `DeepDrftContent.Services/FileDatabase/`. The DeepDrftContent host now contains only `Controllers/`, `Middleware/`, `Models/` (settings POCOs), `environment/`, `Program.cs`, `Startup.cs`.
- `DeepDrftContent/CLAUDE.md` documents only the PUT endpoint; the production API now also has `GET api/track/{id}?offset=` (unauthenticated read, with `WavOffsetService` for offset streaming).
- `DeepDrftWeb/CLAUDE.md` describes EF Core, repositories, services, migrations as living inside `DeepDrftWeb/Data` and `DeepDrftWeb/Services`. They have all moved to `DeepDrftWeb.Services`. The only things still in `DeepDrftWeb` are `Controllers/TrackController.cs`, `Services/DarkModeService.cs`, `Startup.cs`, `Program.cs`, `Components/`, `Interop/`, `wwwroot/`.
- `DeepDrftWeb.Client/CLAUDE.md` lists the `Pages/` directory as containing `Counter.razor` / `Weather.razor` (demo); those are gone. The real client structure is `Pages/Home.razor` + `Pages/TracksView.razor`, plus the `Controls/AudioPlayerBar/` cluster, `Controls/AudioPlayerProvider.razor`, `Services/AudioInteropService.cs` + `AudioPlayerService.cs` + `StreamingAudioPlayerService.cs` + `IPlayerService.cs` + dark-mode services, `Common/DarkModeSettings.cs` + `Common/DDIcons.cs`, and `Layout/Pages.cs` + `Layout/DeepDrftMenu.razor`.
- The `DeepDrftWeb.Services` and `DeepDrftContent.Services` projects have **no** `CLAUDE.md` yet — they are where most of the domain logic actually lives, so this is the biggest gap.
- `DeepDrftCli/CLAUDE.md` references `appsettings.json`; the CLI actually loads `environment/connections.json` into `CliSettings` (with `ConnectionString` and `VaultPath`). The "Available Commands" section is otherwise current, including the `gui` Terminal.Gui mode and interactive `add`.
- `DeepDrftContent.Services/FileDatabase/README.md` (an in-tree dev README, not a CLAUDE.md) refers to `ImageDirectoryVault`; the type is `ImageVault`. It also describes `EntryKey` as removed in favour of strings, which is accurate, but its diagram still says "FileDatabase.csproj (.NET 9.0)" — the FileDatabase no longer has its own csproj at all (it's a subdirectory of `DeepDrftContent.Services`).
**Project-rename drift (the big one).** The two-app split renamed or removed most projects. Any folder `CLAUDE.md` still using the old names is wrong at the structural level, not just the framework-version level:
- `DeepDrftWeb``DeepDrftPublic`; `DeepDrftWeb.Client``DeepDrftPublic.Client`; `DeepDrftWeb.Services``DeepDrftData`.
- `DeepDrftContent.Services` (class library) is now just `DeepDrftContent`; the old `DeepDrftContent` *host* is gone — binary-API duties split between the `DeepDrftPublic` proxy and the `DeepDrftAPI` authority.
- `DeepDrftCli` and the `DeepDrftCms` RCL are **deleted**. Any `CLAUDE.md` for them should be removed, not rewritten.
**Known content drift to correct in the sweep:**
- Framework version: any folder `CLAUDE.md` still saying ".NET 9" / "ASP.NET Core 9.0" — reality is `net10.0` across the board.
- `TrackEntity.MediaPath` references (notably the `FileDatabase/README.md`) — the field is `EntryKey`, column `entry_key`.
- The `FileDatabase/README.md` refers to `ImageDirectoryVault` (the type is `ImageVault`) and a "FileDatabase.csproj (.NET 9.0)" that no longer exists (FileDatabase is a subdirectory of `DeepDrftContent`).
- `DeepDrftData` and `DeepDrftContent` are where most domain logic lives and are the highest-value targets for accurate `CLAUDE.md` coverage.
**Already corrected (no longer stale):**
- `DeepDrftPublic.Client/CLAUDE.md` was rewritten in commit `9110b4b` and reflects the current player stack, `PlaybackIcons`/`PlayStateIcon`, and the post-split structure.
> **`DOC_PLAN.md` caveat.** `DOC_PLAN.md` predates the two-app split — its per-folder briefs reference `DeepDrftWeb*`, `DeepDrftCli`, and a SQLite backend (now PostgreSQL). Treat its *intent* (lead-with-truth, cross-reference root, no docs for build output) as still valid, but its *project list and per-folder details* need reconciling against the current ten-project solution before doc-keeper executes against it. Flag to Daniel whether to refresh `DOC_PLAN.md` first or let doc-keeper work from the root `CLAUDE.md` directly.
+37 -18
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware for track endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
Dual-database authority for tracks (SQL metadata + FileDatabase binary) and images (FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. Two image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for track/image endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only)
@@ -22,21 +22,20 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), and Aut
## What does NOT live here anymore
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.), `WavOffsetService` — all in `DeepDrftContent` (class library).
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.) — all in `DeepDrftContent` (class library).
- EF Core context and repository — in `DeepDrftData`.
- **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration).
## The endpoint surface (seven endpoints)
### GET api/track/{trackId}?offset=0 (unauthenticated)
### GET api/track/{trackId} (unauthenticated)
Returns the WAV bytes from the `tracks` vault with optional offset support.
Returns the WAV bytes from the `tracks` vault with HTTP Range support.
- **Route parameter `trackId`** (string): the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
- **Query parameter `offset`** (optional, default 0): byte position to start streaming from.
- If `offset == 0`: streams the entire file directly from disk without buffering (so 100 MB WAVs do not force 100 MB LOH allocations per request).
- If `offset > 0`: `WavOffsetService.CreateOffsetStream` block-aligns the offset and synthesises a fresh 44-byte WAV header so the response is a valid standalone WAV starting from that byte position. This is load-bearing for seek-beyond-buffer — the player asks for a new stream at the offset it wants to seek to, gets back a valid WAV that starts there, and tears down/re-initialises the decoder.
- Returns 404 if track not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
- **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`).
### PUT api/track/{trackId} ([ApiKeyAuthorize])
@@ -51,21 +50,21 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
### POST api/track/upload ([ApiKeyAuthorize])
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) + metadata as `multipart/form-data`, processes the file, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Form fields**:
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
- `audioFile` (`IFormFile`, required): the audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `trackName` (string, required)
- `artist` (string, required)
- `album` (string, optional)
- `genre` (string, optional)
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
- `createdByUserId` (long, required): audit trail — who uploaded this track.
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackService.AddTrackFromWavAsync` (vault write) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields. Returns 500 if processing fails.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -104,10 +103,29 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request clear optional fields), and persists the DTO via `ITrackService.Update`.
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` (tri-state: null = no change, "" = clear, value = set).
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request for optional metadata clear those fields; `ImagePath` follows tri-state logic), and persists the DTO via `ITrackService.Update`.
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
## The image endpoints (two endpoints)
### POST api/image/upload ([ApiKeyAuthorize])
**Authenticated endpoint.** Accepts an image file upload, stores it in the `images` vault, and returns the entry key.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
- Calls `FileDatabase.RegisterResourceAsync("images", entryKey, imageBinary)` where `imageBinary` is produced by `ImageProcessor` (computes aspect ratio from headers, defaults 1.0 for unsupported formats).
- Returns 200 with JSON `{ entryKey }` on success. Returns 400 for missing file. Returns 500 if processing or vault operations fail.
### GET api/image/{entryKey} (unauthenticated)
Returns image bytes from the `images` vault.
- **Route parameter `entryKey`** (string): the entry id inside the `images` vault.
- Streams the image file directly from disk without buffering.
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
@@ -141,7 +159,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -232,7 +251,7 @@ dotnet build DeepDrftAPI
curl -H "ApiKey: your-secret-key" -X GET https://localhost:5002/api/track/page \
-H "Accept: application/json"
curl https://localhost:5002/api/track/test-entry-key?offset=0
curl https://localhost:5002/api/track/test-entry-key
# Test auth endpoints (AuthBlocks API)
curl -X POST https://localhost:5002/api/auth/login \
+125
View File
@@ -0,0 +1,125 @@
using DeepDrftAPI.Middleware;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.Processors;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ImageController : ControllerBase
{
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
private const int MaxImageBytes = 50_000_000;
// FileDatabase is injected directly because image operations are vault-only: there is no
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
// written separately via PUT api/track/meta/{id}.
private readonly FileDatabase _fileDatabase;
private readonly ImageProcessor _imageProcessor;
private readonly ILogger<ImageController> _logger;
public ImageController(
FileDatabase fileDatabase,
ImageProcessor imageProcessor,
ILogger<ImageController> logger)
{
_fileDatabase = fileDatabase;
_imageProcessor = imageProcessor;
_logger = logger;
}
// POST api/image/upload ([ApiKeyAuthorize])
// Stores a cover-art image in the images vault and returns its generated entry key. Images
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(MaxImageBytes)]
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
{
if (image is null || image.Length == 0)
{
return BadRequest("Image file is required");
}
if (image.Length > MaxImageBytes)
{
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
}
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
{
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
byte[] buffer;
await using (var stream = image.OpenReadStream())
using (var memory = new MemoryStream())
{
await stream.CopyToAsync(memory, cancellationToken);
buffer = memory.ToArray();
}
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
if (imageBinary is null)
{
// Process only returns null for an unsupported content type, already screened above —
// belt-and-suspenders in case ImageProcessor's validation diverges later.
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
var entryKey = Guid.NewGuid().ToString("N");
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
if (!stored)
{
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return StatusCode(500, "Failed to store image");
}
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
entryKey, image.ContentType, buffer.Length);
return Ok(new { entryKey });
}
// GET api/image/{entryKey} (unauthenticated)
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
// is disposed only on the catch path.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetImage(string entryKey)
{
var vault = _fileDatabase.GetVault(VaultConstants.Images);
if (vault is null)
{
_logger.LogWarning("Images vault not found");
return NotFound();
}
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
if (mediaStream is null)
{
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
return NotFound();
}
string mimeType;
Stream innerStream;
try
{
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
innerStream = mediaStream.Stream;
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
return File(innerStream, mimeType, enableRangeProcessing: false);
}
}
+296 -89
View File
@@ -1,11 +1,13 @@
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Models;
using DeepDrftAPI.Services;
using DeepDrftContent.Audio;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
@@ -15,9 +17,9 @@ namespace DeepDrftAPI.Controllers;
public class TrackController : ControllerBase
{
private readonly DeepDrftContent.TrackContentService _trackContentService;
private readonly WavOffsetService _wavOffsetService;
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -29,16 +31,16 @@ public class TrackController : ControllerBase
public TrackController(
DeepDrftContent.TrackContentService trackContentService,
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
WavOffsetService wavOffsetService,
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
_fileDatabase = fileDatabase;
_wavOffsetService = wavOffsetService;
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
@@ -46,17 +48,24 @@ public class TrackController : ControllerBase
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering).
[HttpGet("page")]
public async Task<ActionResult> GetPage(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
CancellationToken cancellationToken = default)
{
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken);
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre };
var effectiveFilter = filter.IsEmpty ? null : filter;
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
@@ -67,11 +76,104 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
// GET api/track/albums (unauthenticated)
// All releases with per-release track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
[HttpGet("albums")]
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetReleases(ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetAlbums failed: {Error}", error);
return StatusCode(500, "Failed to load albums");
}
return Ok(result.Value);
}
// GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
[HttpGet("genres")]
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetDistinctGenres(ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetGenres failed: {Error}", error);
return StatusCode(500, "Failed to load genres");
}
return Ok(result.Value);
}
// GET api/track/random (unauthenticated)
// Picks one track at random from the full library and returns its metadata. Public, same auth
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
[HttpGet("random")]
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
{
var result = await _sqlTrackService.GetRandom(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetRandom failed: {Error}", error);
return StatusCode(500, "Failed to load track");
}
if (result.Value is null)
{
return NotFound();
}
return Ok(result.Value);
}
// GET api/track/waveform-status ([ApiKeyAuthorize])
// Admin backfill view: returns every track with a flag for whether a waveform profile is
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
[HttpGet("waveform-status")]
public async Task<ActionResult> GetWaveformStatus()
{
var tracks = await _sqlTrackService.GetAll();
if (!tracks.Success || tracks.Value is null)
{
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
return StatusCode(500, "Failed to load tracks");
}
var status = new List<WaveformStatusDto>(tracks.Value.Count);
foreach (var track in tracks.Value)
{
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
});
}
return Ok(status);
}
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
// proxies the upload here so it never touches the vault disk path or SQL directly.
// UnifiedTrackService owns the two-database write.
//
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
// not a buffered allocation.
@@ -80,21 +182,24 @@ public class TrackController : ControllerBase
[RequestSizeLimit(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
[FromForm] IFormFile? wav,
[FromForm] IFormFile? audioFile,
[FromForm] string? trackName,
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
[FromForm] string? releaseType,
[FromForm] int? trackNumber,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
trackName, artist, wav?.Length);
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
trackName, artist, originalFileName, audioFile?.Length);
if (wav is null || wav.Length == 0)
if (audioFile is null || audioFile.Length == 0)
{
return BadRequest("WAV file is required");
return BadRequest("Audio file is required");
}
if (string.IsNullOrWhiteSpace(trackName))
@@ -107,9 +212,10 @@ public class TrackController : ControllerBase
return BadRequest("artist is required");
}
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
{
return BadRequest("Uploaded file must have a .wav extension");
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
DateOnly? parsedReleaseDate = null;
@@ -122,16 +228,33 @@ public class TrackController : ControllerBase
parsedReleaseDate = parsed;
}
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
// Default to Single for null/unparseable release type; default track number to a valid 1-based value.
ReleaseType parsedReleaseType;
if (!string.IsNullOrWhiteSpace(releaseType)
&& Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
&& Enum.IsDefined(rt))
{
parsedReleaseType = rt;
}
else
{
parsedReleaseType = ReleaseType.Single;
if (!string.IsNullOrWhiteSpace(releaseType))
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
}
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);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = wav.OpenReadStream())
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
@@ -144,11 +267,14 @@ public class TrackController : ControllerBase
string.IsNullOrWhiteSpace(genre) ? null : genre,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
parsedReleaseType,
resolvedTrackNumber,
cancellationToken);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
return StatusCode(500, error);
}
@@ -198,6 +324,28 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
// Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
// public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
[HttpGet("meta/by-key/{entryKey}")]
public async Task<ActionResult> GetMetaByKey(string entryKey)
{
var result = await _sqlTrackService.GetByEntryKey(entryKey);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load track");
}
if (result.Value is null)
{
return NotFound();
}
return Ok(result.Value);
}
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
[ApiKeyAuthorize]
[HttpPut("meta/{id:long}")]
@@ -216,12 +364,36 @@ public class TrackController : ControllerBase
return NotFound();
}
if (request.TrackNumber is <= 0)
return BadRequest("trackNumber must be a positive integer when provided.");
var track = lookup.Value;
// Track-cardinal fields update the track row directly.
track.TrackName = request.TrackName;
track.Artist = request.Artist;
track.Album = request.Album;
track.Genre = request.Genre;
track.ReleaseDate = request.ReleaseDate;
if (request.TrackNumber is > 0)
track.TrackNumber = request.TrackNumber.Value;
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
// its Release populated via the Include; mutate it in place so the edited values flow through.
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
// write to — so these fields are simply not persisted in that case.
if (track.Release is { } release)
{
release.Artist = request.Artist;
release.Title = request.Album ?? string.Empty;
release.Genre = request.Genre;
release.ReleaseDate = request.ReleaseDate;
// ImagePath is tri-state: null = no change, "" = clear, value = set.
if (request.ImagePath is not null)
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
// ReleaseType is non-null on the release; null in the request means "no change".
if (request.ReleaseType is not null)
release.ReleaseType = request.ReleaseType.Value;
}
var update = await _sqlTrackService.Update(track);
if (!update.Success)
@@ -259,87 +431,74 @@ public class TrackController : ControllerBase
return StatusCode(500, error);
}
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
// block so it never resolves to the parameterized "{trackId}" GET.
[ApiKeyAuthorize]
[HttpDelete("release/{id:long}")]
public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
{
var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
if (result.Success) return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
return StatusCode(500, error);
}
// --- Parameterized routes ---
[HttpGet("{trackId}")]
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] long offset = 0)
public async Task<ActionResult> GetTrack(string trackId)
{
_logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset);
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
try
{
// No-offset path: stream the file straight from disk so a 100 MB WAV does not
// force a 100 MB LOH allocation per request. The offset path still loads
// the full buffer because WavOffsetService block-aligns and reslices into
// a composite stream over the in-memory buffer.
if (offset == 0)
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault == null)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault == null)
{
_logger.LogWarning("Tracks vault not found");
return NotFound();
}
var mediaStream = await vault.GetEntryStreamAsync(trackId);
if (mediaStream == null)
{
_logger.LogWarning("Track not found: {TrackId}", trackId);
return NotFound();
}
// Resolve MIME and log before handing the stream to File().
// If anything here throws, the finally 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
// with no extra resources, so disposing it after extracting the
// inner stream is a no-op — we only call Dispose() in the catch path.
string streamMimeType;
long streamLength;
Stream innerStream;
try
{
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
streamLength = mediaStream.Stream.Length;
innerStream = mediaStream.Stream;
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
_logger.LogInformation(
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
trackId, streamLength);
// enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
return File(innerStream, streamMimeType, enableRangeProcessing: false);
_logger.LogWarning("Tracks vault not found");
return NotFound();
}
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
// orchestrator boundary) so the controller stays out of FileDatabase directly.
// The buffered AudioBinary is required because WavOffsetService block-aligns
// and reslices into a composite stream over the in-memory buffer.
var file = await _trackContentService.GetAudioBinaryAsync(trackId);
if (file == null)
var mediaStream = await vault.GetEntryStreamAsync(trackId);
if (mediaStream == null)
{
_logger.LogWarning("Track not found: {TrackId}", trackId);
return NotFound();
}
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
if (offsetStream == null)
// Resolve MIME and log before handing the stream to File().
// If anything here throws, the finally 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
// with no extra resources, so disposing it after extracting the
// inner stream is a no-op — we only call Dispose() in the catch path.
string streamMimeType;
long streamLength;
Stream innerStream;
try
{
_logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId);
return BadRequest("Invalid offset");
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
streamLength = mediaStream.Stream.Length;
innerStream = mediaStream.Stream;
}
catch
{
await mediaStream.DisposeAsync();
throw;
}
_logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes",
trackId, offset, offsetStream.Length);
return File(offsetStream, mimeType);
_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.
return File(innerStream, streamMimeType, enableRangeProcessing: true);
}
catch (Exception ex)
{
@@ -348,6 +507,54 @@ public class TrackController : ControllerBase
}
}
// 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
// (existing tracks predate profiling, or computation failed at upload — the frontend falls back
// to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
[HttpGet("{trackId}/waveform")]
public async Task<ActionResult> GetWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId);
if (bytes is null)
{
_logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
// tracks that predate the WaveformSeeker feature.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform")]
public async Task<ActionResult> GenerateWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
{
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
if (!stored)
{
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate waveform profile.");
}
return Ok();
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
-1
View File
@@ -26,4 +26,3 @@
</Project>
@@ -1,12 +1,22 @@
using DeepDrftModels.Enums;
namespace DeepDrftAPI.Models;
/// <summary>
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
/// travels over this surface.
/// </summary>
/// <remarks>
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
/// non-empty value is the images-vault entry key to link.
/// </remarks>
public record UpdateTrackMetadataRequest(
string TrackName,
string Artist,
string? Album,
string? Genre,
DateOnly? ReleaseDate);
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
int? TrackNumber = null);
+103 -7
View File
@@ -1,7 +1,9 @@
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
@@ -18,24 +20,28 @@ public class UnifiedTrackService
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<UnifiedTrackService> _logger;
public UnifiedTrackService(
TrackContentService contentTrackContentService,
ITrackService sqlTrackService,
FileDb fileDatabase,
WaveformProfileService waveformProfileService,
ILogger<UnifiedTrackService> logger)
{
_contentTrackContentService = contentTrackContentService;
_sqlTrackService = sqlTrackService;
_fileDatabase = fileDatabase;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
/// <summary>
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
/// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
/// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
/// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
/// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
/// recoverable manually.
/// </summary>
public async Task<ResultContainer<TrackDto>> UploadAsync(
string tempFilePath,
@@ -45,10 +51,13 @@ public class UnifiedTrackService
string? genre,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
ReleaseType releaseType,
int trackNumber,
CancellationToken ct)
{
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
tempFilePath, trackName, artist, album, genre, releaseDate);
var unpersisted = await _contentTrackContentService.AddTrackAsync(
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
if (unpersisted is null)
{
@@ -56,9 +65,43 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
}
unpersisted.CreatedByUserId = createdByUserId;
unpersisted.TrackNumber = trackNumber;
var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted));
// Resolve the release FK before persisting the track. An upload with an album lands on the
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
// on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
{
var releaseData = new ReleaseDto
{
Title = album,
Artist = artist,
Genre = genre,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
CreatedByUserId = createdByUserId,
};
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null)
{
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
unpersisted.EntryKey, error);
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
releaseId = releaseResult.Value.Id;
}
var trackDto = TrackConverter.Convert(unpersisted);
trackDto.ReleaseId = releaseId;
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
var saveResult = await _sqlTrackService.Create(trackDto);
if (!saveResult.Success || saveResult.Value is null)
{
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
@@ -70,9 +113,27 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform profile: both stores succeeded, so the upload is a success
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
return saveResult;
}
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
{
try
{
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
/// <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
@@ -94,6 +155,7 @@ public class UnifiedTrackService
}
var entryKey = lookup.Value.EntryKey;
var releaseId = lookup.Value.ReleaseId;
var sqlDelete = await _sqlTrackService.Delete(id);
if (!sqlDelete.Success)
@@ -103,6 +165,14 @@ public class UnifiedTrackService
return Result.CreateFailResult("Failed to delete track.");
}
// Cascade: if this was the last live track on its release, soft-delete the release too so it
// does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
// already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
if (releaseId is { } rid)
{
await TrySoftDeleteEmptyReleaseAsync(rid, ct);
}
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
@@ -115,4 +185,30 @@ public class UnifiedTrackService
return Result.CreatePassResult();
}
// Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
// failure here never fails the track delete that triggered it — it is logged so an orphaned
// release can be cleaned up later (the migration backfill also catches pre-existing orphans).
private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
{
var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
if (!countResult.Success)
{
var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
return;
}
if (countResult.Value > 0)
{
return;
}
var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
if (!releaseDelete.Success)
{
var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
}
}
}
+21 -2
View File
@@ -1,6 +1,5 @@
using DeepDrftAPI.Models;
using DeepDrftContent;
using DeepDrftContent.Audio;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
@@ -15,10 +14,21 @@ namespace DeepDrftAPI
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
{
// Audio services
builder.Services.AddSingleton<WavOffsetService>();
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<Mp3AudioProcessor>();
builder.Services.AddSingleton<FlacAudioProcessor>();
builder.Services.AddSingleton<AudioProcessorRouter>();
builder.Services.AddSingleton<TrackContentService>();
// Image services
builder.Services.AddSingleton<ImageProcessor>();
// Waveform loudness profiling (upload-time, off the playback path)
builder.Services.Configure<WaveformProfileOptions>(
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
builder.Services.AddSingleton<ILoudnessAlgorithm, RmsLoudnessAlgorithm>();
builder.Services.AddSingleton<WaveformProfileService>();
// File Database
var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json");
builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false);
@@ -32,6 +42,7 @@ namespace DeepDrftAPI
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
if (db is null) throw new Exception("Unable to initialize file database");
InitializeTrackVault(db).GetAwaiter().GetResult();
InitializeImageVault(db).GetAwaiter().GetResult();
return db;
});
@@ -45,5 +56,13 @@ namespace DeepDrftAPI
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
}
private static async Task InitializeImageVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Images))
{
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
}
}
}
}
-318
View File
@@ -1,318 +0,0 @@
using System.Text;
namespace DeepDrftContent.Audio;
/// <summary>
/// Service for creating WAV audio streams starting from a byte offset.
/// Synthesizes a valid WAV header for the remaining audio data.
/// </summary>
public class WavOffsetService
{
/// <summary>
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
/// (format 3) and other formats are rejected at parse time so the
/// synthesized header here can safely assume PCM.
/// </summary>
public const short PcmFormat = 1;
/// <summary>
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
/// buffer — no copy of the audio payload is made.
/// </summary>
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
{
var format = ParseWavHeader(fullAudioBuffer);
if (format == null)
return null;
// Validate offset is within bounds and block-aligned
if (byteOffset < 0 || byteOffset >= format.DataSize)
return null;
// Align to block boundary for clean audio
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
var newDataSize = format.DataSize - alignedOffset;
if (newDataSize <= 0)
return null;
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
var sourcePosition = format.HeaderSize + alignedOffset;
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
var newDataSizeInt = (int)newDataSize;
var sourcePositionInt = (int)sourcePosition;
// Create new WAV header using the format reported by the parsed header.
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
// threading format.AudioFormat through keeps the header self-consistent
// and prevents drift if the validation contract is ever relaxed.
var newHeader = CreateWavHeader(format, newDataSizeInt);
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
// that the previous MemoryStream(capacity).Write(...) implementation forced.
var headerStream = new MemoryStream(newHeader, writable: false);
var dataStream = new MemoryStream(
fullAudioBuffer,
sourcePositionInt,
newDataSizeInt,
writable: false,
publiclyVisible: false);
return new ConcatStream(headerStream, dataStream);
}
/// <summary>
/// Parses the WAV header from a buffer to extract format information.
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
/// so downstream synthesis can safely assume PCM sample encoding.
/// </summary>
public WavFormat? ParseWavHeader(byte[] buffer)
{
if (buffer.Length < 44)
return null;
// Check RIFF header
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
if (riff != "RIFF")
return null;
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
if (wave != "WAVE")
return null;
// Variables to store parsed header info
int sampleRate = 0;
int channels = 0;
int bitsPerSample = 0;
int byteRate = 0;
int blockAlign = 0;
long dataSize = 0;
int headerSize = 0;
short audioFormat = 0;
bool foundFmt = false;
bool foundData = false;
// Find fmt and data chunks
int chunkOffset = 12;
while (chunkOffset < buffer.Length - 8)
{
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
if (chunkSize < 0)
return null;
if (chunkId == "fmt " && !foundFmt)
{
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
// AudioProcessor.FindChunk which also returns the first match.
if (chunkSize < 16)
return null;
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
// PCM only. Float32 WAVs were previously accepted here but the synthesized
// header below is PCM-shaped — accepting Float would produce a corrupt file
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
// non-PCM at upload time so this branch is defense in depth.
if (audioFormat != PcmFormat)
return null;
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
// Basic validation
if (channels < 1 || channels > 8)
return null;
foundFmt = true;
}
else if (chunkId == "data")
{
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
// values above int.MaxValue (files between 24 GB), then widen to long.
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
foundData = true;
}
// Move to next chunk with proper alignment (chunks are word-aligned)
chunkOffset += 8 + ((chunkSize + 1) & ~1);
// If we found both chunks, we're done
if (foundFmt && foundData)
break;
}
// Must have found both fmt and data chunks
if (!foundFmt || !foundData)
return null;
return new WavFormat(
AudioFormat: audioFormat,
SampleRate: sampleRate,
Channels: channels,
BitsPerSample: bitsPerSample,
ByteRate: byteRate,
BlockAlign: blockAlign,
DataSize: dataSize,
HeaderSize: headerSize
);
}
/// <summary>
/// Creates a standard 44-byte WAV header. The audio format code is taken from
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
/// </summary>
public byte[] CreateWavHeader(WavFormat format, int dataSize)
{
var header = new byte[44];
var fileSize = 36 + dataSize;
// RIFF header
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
// fmt chunk
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
// data chunk header
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
return header;
}
}
/// <summary>
/// WAV format information extracted from header.
/// </summary>
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
public record WavFormat(
short AudioFormat,
int SampleRate,
int Channels,
int BitsPerSample,
int ByteRate,
int BlockAlign,
long DataSize,
int HeaderSize
);
/// <summary>
/// Forward-only read stream over two underlying streams concatenated end-to-end.
/// Lets us serve "[synthesized header][slice of original buffer]" without
/// allocating a single contiguous buffer for the combined payload.
/// </summary>
internal sealed class ConcatStream : Stream
{
private readonly Stream _first;
private readonly Stream _second;
private readonly long _length;
private long _position;
public ConcatStream(Stream first, Stream second)
{
_first = first;
_second = second;
_length = first.Length + second.Length;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
var total = 0;
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
// is full. Stream.Read is not required to fill the buffer in one call even
// when data is available (e.g. a future non-MemoryStream _first), so we must
// keep pulling until we get 0 before advancing to _second.
while (count > 0 && _position < _first.Length)
{
var read = _first.Read(buffer, offset, count);
if (read == 0) break;
total += read;
_position += read;
offset += read;
count -= read;
}
if (count > 0)
{
var read = _second.Read(buffer, offset, count);
total += read;
_position += read;
}
return total;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var total = 0;
// Same loop contract as Read() — exhaust _first before reading _second.
while (!buffer.IsEmpty && _position < _first.Length)
{
var read = await _first.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
total += read;
_position += read;
buffer = buffer[read..];
}
if (!buffer.IsEmpty)
{
var read = await _second.ReadAsync(buffer, cancellationToken);
total += read;
_position += read;
}
return total;
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_first.Dispose();
_second.Dispose();
}
base.Dispose(disposing);
}
}
+17 -22
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), WAV stream-with-offset, audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
## Layout
@@ -17,8 +17,6 @@ DeepDrftContent.Services/
│ ├── Models/ # Data models, DTOs, enums
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
├── Audio/
│ └── WavOffsetService.cs # Byte offset → valid WAV stream
├── Processors/
│ └── AudioProcessor.cs # WAV file parsing, metadata extraction
├── Constants/
@@ -76,30 +74,28 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
## WAV offset service
## Audio processors
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`:
1. Parses the WAV header from the buffer.
2. Block-aligns the byte offset to the nearest block boundary (required for clean audio — misalignment causes clicks).
3. Synthesises a new 44-byte WAV header sized for the remaining data (from offset to EOF).
4. Returns a `MemoryStream` containing `[new header][data from offset]`.
- `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.
Used by the content API to serve seek-beyond-buffer requests. The player asks for a new stream at the byte offset it wants to seek to; the server returns a valid WAV that starts there.
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType)` — format-agnostic. It selects the right processor via `AudioProcessorRouter`, processes the file, generates an entry GUID, stores in vault, returns unpersisted `TrackEntity`. Legacy `AddTrackFromWavAsync(filePath)` is now a shim over `AddTrackAsync` for backward compatibility.
## Audio processor
## Image processor
`AudioProcessor.ProcessWavFileAsync(filePath)`:
`ImageProcessor.ProcessImageAsync(buffer, mimeType)`:
1. Validates the RIFF/WAVE/PCM structure.
2. Parses the fmt and data chunks.
3. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
4. Returns `AudioBinary` with all metadata.
5. **Fallback**: If parsing fails, logs a warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo).
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
1. Accepts raw image bytes and MIME type (e.g., `image/png`, `image/jpeg`).
2. Parses PNG or JPEG headers to extract image dimensions.
3. Computes aspect ratio (width / height). Defaults to 1.0 if parsing fails or format is unsupported.
4. Returns `ImageBinary` with MIME type and aspect ratio metadata.
5. **No disk I/O**: operates on `byte[]` only — no file reading required.
## Content-side TrackService (orchestrator)
@@ -124,14 +120,13 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
## Vault constants
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
## Service registration
In `DeepDrftContent/Startup.ConfigureDomainServices()` and `DeepDrftCli/Program.cs`:
```csharp
services.AddSingleton<WavOffsetService>();
services.AddSingleton<FileDatabase>(/* from FileDatabase.FromAsync */);
services.AddScoped<AudioProcessor>();
services.AddScoped<TrackService>(); // DeepDrftContent.Services.TrackService
@@ -9,4 +9,15 @@ public static class VaultConstants
/// Vault name for storing audio tracks
/// </summary>
public const string Tracks = "tracks";
/// <summary>
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
/// </summary>
public const string WaveformProfiles = "waveform-profiles";
/// <summary>
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
/// from <c>TrackEntity.ImagePath</c>.
/// </summary>
public const string Images = "images";
}
+1
View File
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
@@ -219,6 +219,32 @@ public class AudioVault : MediaVault
}
}
/// <summary>
/// Concrete vault for plain <see cref="MediaBinary"/> entries (vault type
/// <see cref="MediaVaultType.Media"/>) — bytes plus an extension, no audio/image-specific
/// metadata. Used for sidecar artifacts such as waveform loudness profiles. The base
/// <see cref="MediaVault"/> already handles Media-typed storage via the registry; this only
/// provides the concrete factory the Image and Audio vaults also provide.
/// </summary>
public class MediaFileVault : MediaVault
{
private MediaFileVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
: base(rootPath, index, factoryService) { }
public static async Task<MediaFileVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
{
var factory = factoryService ?? new IndexFactoryService();
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Media);
if (index != null)
{
return new MediaFileVault(rootPath, (VaultIndex)index, factory);
}
return null;
}
}
/// <summary>
/// An open read-only stream over a vault entry plus the extension needed to
/// resolve its MIME type. Caller owns the stream and must dispose it.
@@ -11,6 +11,7 @@ public static class MediaVaultFactory
{
return mediaType switch
{
MediaVaultType.Media => await MediaFileVault.FromAsync(rootPath, factoryService),
MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService),
MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService),
_ => null
@@ -31,7 +31,8 @@ public class SimpleMediaTypeRegistry : IMediaTypeRegistry
dto => MediaBinary.From(dto),
binary => new MediaBinaryDto(binary),
(key, ext, _) => new MetaData(key, ext),
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension));
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension),
async path => await MediaFileVault.FromAsync(path));
RegisterType<ImageBinary, ImageBinaryParams, ImageBinaryDto, ImageMetaData>(
MediaVaultType.Image,
+300 -12
View File
@@ -28,10 +28,15 @@ public class AudioProcessor
{
var buffer = await File.ReadAllBytesAsync(filePath);
var wavInfo = ExtractWavMetadata(buffer);
// 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;
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Buffer: storedBuffer,
Size: storedBuffer.Length,
Extension: ".wav",
Duration: wavInfo.Duration,
Bitrate: wavInfo.Bitrate
@@ -45,6 +50,67 @@ public class AudioProcessor
}
}
/// <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
/// a valid PCM WAV (callers treat a null as "no profile computable" and continue) — unlike
/// <see cref="ExtractWavMetadata"/>, this does NOT fall back to synthetic defaults, because a
/// loudness profile over fabricated silence would be misleading.
/// </summary>
public PcmData? TryExtractPcm(ReadOnlySpan<byte> buffer)
{
// Copy the span to an array so the existing array-based parsers can be reused. The PCM
// slice returned is a view over this array (no second copy of the data region).
var bytes = buffer.ToArray();
var validation = ValidateWavStructure(bytes);
if (!validation.IsValid)
{
return null;
}
// Float and padded-container EXTENSIBLE require a sample-level transform to become integer PCM.
// TryExtractPcm feeds loudness analysis, not storage, and must not hand back float bytes
// mislabeled as integer PCM — out of scope here, so treat them as "no profile computable".
if (validation.IsFloat)
{
return null;
}
WavMetadata metadata;
try
{
metadata = ParseWavMetadata(bytes, validation);
ValidateAudioParameters(metadata);
if (metadata.IsPaddedContainer)
{
return null;
}
}
catch
{
return null;
}
// Data bytes begin 8 past the "data" chunk id (4 id + 4 size). Clamp the declared size to
// what is actually present — some encoders write a size that overshoots the file.
var dataStart = validation.DataChunkPos + 8;
if (dataStart > bytes.Length)
{
return null;
}
var available = bytes.Length - dataStart;
var dataLength = Math.Min(metadata.DataSize, available);
if (dataLength <= 0)
{
return null;
}
var pcm = new ReadOnlyMemory<byte>(bytes, dataStart, dataLength);
return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
}
/// <summary>
/// Extracts metadata from WAV file buffer with comprehensive validation
/// </summary>
@@ -107,9 +173,46 @@ public class AudioProcessor
return new WavValidationResult { IsValid = false, ErrorMessage = "fmt chunk too small" };
}
// Validate audio format (PCM only)
// Validate audio format. Standard PCM (1) is accepted directly. WAVE_FORMAT_EXTENSIBLE
// (0xFFFE) is accepted when its SubFormat GUID indicates PCM (0x0001) or IEEE float
// (0x0003). PCM sample data is byte-identical to standard PCM; float data is converted to
// 24-bit PCM downstream. Either way the vault only ever holds standard PCM.
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
if (audioFormat != 1)
var isExtensible = false;
var isFloat = false;
if (audioFormat == 0xFFFE)
{
// EXTENSIBLE requires the full extension: 16 base + 2 cbSize + 22 extension = 40 bytes.
if (fmtChunkSize < 40)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk too small" };
}
if (fmtChunkPos + 8 + 40 > buffer.Length)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk extends past end of file" };
}
// SubFormat GUID begins 24 bytes into the fmt chunk data (fmtChunkPos + 8 + 24). Its
// first two bytes are the little-endian format tag: 0x0001 == WAVE_FORMAT_PCM,
// 0x0003 == WAVE_FORMAT_IEEE_FLOAT.
var subFormatPos = fmtChunkPos + 8 + 24;
var subFormatTag = BitConverter.ToUInt16(buffer, subFormatPos);
if (subFormatTag == 0x0001)
{
isExtensible = true;
}
else if (subFormatTag == 0x0003)
{
isExtensible = true;
isFloat = true;
}
else
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is neither PCM nor IEEE float" };
}
}
else if (audioFormat != 1)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
}
@@ -121,11 +224,13 @@ public class AudioProcessor
return new WavValidationResult { IsValid = false, ErrorMessage = "Missing data chunk" };
}
return new WavValidationResult
{
IsValid = true,
return new WavValidationResult
{
IsValid = true,
FmtChunkPos = fmtChunkPos,
DataChunkPos = dataChunkPos
DataChunkPos = dataChunkPos,
IsExtensible = isExtensible,
IsFloat = isFloat
};
}
@@ -141,6 +246,23 @@ public class AudioProcessor
var bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 22);
var dataSize = BitConverter.ToUInt32(buffer, validation.DataChunkPos + 4);
// For EXTENSIBLE the offset-22 field is the container width; the true sample depth lives in
// wValidBitsPerSample (fmtChunkPos + 8 + 18). They usually match (Bandcamp 24-bit = 24/24)
// but the valid bits are authoritative for the normalized header and metadata. When they
// differ (e.g. 24-bit valid in a 32-bit container) we keep the container width separately so
// ValidateAudioParameters can reconcile against the header BlockAlign and NormalizeToStandardPcm
// can re-pack the padded frames.
var containerBitsPerSample = 0;
if (validation.IsExtensible)
{
var validBits = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
if (validBits != bitsPerSample)
{
containerBitsPerSample = bitsPerSample;
}
bitsPerSample = validBits;
}
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
var bitrate = (int)((sampleRate * channels * bitsPerSample) / 1000);
@@ -151,8 +273,12 @@ public class AudioProcessor
SampleRate = (int)sampleRate,
Channels = channels,
BitsPerSample = bitsPerSample,
ContainerBitsPerSample = containerBitsPerSample,
BlockAlign = blockAlign,
DataSize = (int)dataSize
DataSize = (int)dataSize,
DataChunkPos = validation.DataChunkPos,
IsExtensible = validation.IsExtensible,
IsFloat = validation.IsFloat
};
}
@@ -179,13 +305,140 @@ public class AudioProcessor
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
}
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
// The header BlockAlign reflects the container width, not the valid bit depth. For a padded
// EXTENSIBLE container (e.g. 24-in-32) the container width is authoritative for this check;
// NormalizeToStandardPcm re-packs the frames down to the valid depth afterwards.
var blockAlignBits = metadata.IsPaddedContainer ? metadata.ContainerBitsPerSample : metadata.BitsPerSample;
var expectedBlockAlign = metadata.Channels * (blockAlignBits / 8);
if (metadata.BlockAlign != expectedBlockAlign)
{
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
}
}
/// <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"/>).
/// </summary>
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
{
// 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 blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
var byteRate = (uint)(metadata.SampleRate * blockAlign);
// RIFF header
System.Text.Encoding.ASCII.GetBytes("RIFF").CopyTo(result, 0);
BitConverter.GetBytes((uint)(36 + dataSize)).CopyTo(result, 4);
System.Text.Encoding.ASCII.GetBytes("WAVE").CopyTo(result, 8);
// fmt chunk (standard 16-byte PCM)
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(byteRate).CopyTo(result, 28);
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
// data chunk
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;
}
/// <summary>
/// Converts 32-bit little-endian IEEE float samples (range [-1.0, 1.0]) to 24-bit signed PCM.
/// Each 4-byte source sample becomes 3 little-endian output bytes; output size is 3/4 of input.
/// Trailing bytes that do not form a complete 4-byte sample are ignored.
/// </summary>
private static byte[] ConvertFloatTo24BitPcm(byte[] buffer, int dataStart, int dataSize)
{
var sampleCount = dataSize / 4;
var output = new byte[sampleCount * 3];
for (int i = 0; i < sampleCount; i++)
{
var sample = BitConverter.ToSingle(buffer, dataStart + i * 4);
var value = (int)(sample * 8388607.0);
value = Math.Clamp(value, -8388608, 8388607);
var o = i * 3;
output[o] = (byte)(value & 0xFF);
output[o + 1] = (byte)((value >> 8) & 0xFF);
output[o + 2] = (byte)((value >> 16) & 0xFF);
}
return output;
}
/// <summary>
/// Strips container padding from a padded-container EXTENSIBLE WAV (e.g. 24-bit valid samples
/// stored in 32-bit containers), keeping only the lowest <paramref name="validBits"/> bytes of
/// each little-endian sample. Output size is (validBits/containerBits) of input.
/// Trailing bytes that do not form a complete container sample are ignored.
/// </summary>
private static byte[] RepackPaddedContainer(byte[] buffer, int dataStart, int dataSize, int containerBits, int validBits)
{
var containerBytes = containerBits / 8;
var validBytes = validBits / 8;
var sampleCount = dataSize / containerBytes;
var output = new byte[sampleCount * validBytes];
for (int i = 0; i < sampleCount; i++)
{
var src = dataStart + i * containerBytes;
var dst = i * validBytes;
// Little-endian: the valid sample occupies the low bytes; the upper bytes are padding /
// sign extension and are discarded.
for (int b = 0; b < validBytes; b++)
{
output[dst + b] = buffer[src + b];
}
}
return output;
}
/// <summary>
/// Returns default WAV metadata for fallback scenarios
/// </summary>
@@ -253,9 +506,26 @@ public class AudioProcessor
public int Bitrate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
/// <summary>The valid sample depth — for EXTENSIBLE, wValidBitsPerSample.</summary>
public int BitsPerSample { get; set; }
/// <summary>
/// The container sample width for a padded EXTENSIBLE WAV whose valid depth is narrower
/// (e.g. 32 for a 24-in-32 file). Zero when the container matches the valid depth.
/// </summary>
public int ContainerBitsPerSample { get; set; }
public int BlockAlign { get; set; }
public int DataSize { get; set; }
public int DataChunkPos { get; set; }
public bool IsExtensible { get; set; }
/// <summary>True when the SubFormat is IEEE float (converted to 24-bit PCM on normalization).</summary>
public bool IsFloat { get; set; }
/// <summary>True when valid samples are stored in a wider container that must be re-packed.</summary>
public bool IsPaddedContainer => ContainerBitsPerSample != 0 && ContainerBitsPerSample != BitsPerSample;
}
/// <summary>
@@ -267,5 +537,23 @@ public class AudioProcessor
public string ErrorMessage { get; set; } = string.Empty;
public int FmtChunkPos { get; set; }
public int DataChunkPos { get; set; }
public bool IsExtensible { get; set; }
/// <summary>True when the EXTENSIBLE SubFormat is IEEE float rather than PCM.</summary>
public bool IsFloat { get; set; }
}
}
}
/// <summary>
/// The raw PCM sample region of a WAV plus the format parameters needed to interpret it.
/// <see cref="Pcm"/> is a view over the decoded buffer — the data chunk only, header excluded.
/// </summary>
/// <param name="Pcm">The PCM sample bytes (interleaved by channel, little-endian).</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 PcmData(
ReadOnlyMemory<byte> Pcm,
int Channels,
int SampleRate,
int BitsPerSample);
@@ -0,0 +1,42 @@
using DeepDrftContent.FileDatabase.Models;
namespace DeepDrftContent.Processors;
/// <summary>
/// Dispatches an audio file to the correct format processor by extension. The single seam through
/// which <see cref="TrackContentService"/> processes uploads, so callers depend on one abstraction
/// rather than three concrete processors.
/// </summary>
public class AudioProcessorRouter
{
private readonly AudioProcessor _wavProcessor;
private readonly Mp3AudioProcessor _mp3Processor;
private readonly FlacAudioProcessor _flacProcessor;
public AudioProcessorRouter(
AudioProcessor wavProcessor,
Mp3AudioProcessor mp3Processor,
FlacAudioProcessor flacProcessor)
{
_wavProcessor = wavProcessor;
_mp3Processor = mp3Processor;
_flacProcessor = flacProcessor;
}
/// <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.
/// </summary>
public async Task<AudioBinary?> ProcessAudioFileAsync(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath),
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath),
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath),
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
};
}
}
@@ -0,0 +1,104 @@
using DeepDrftContent.FileDatabase.Models;
namespace DeepDrftContent.Processors;
/// <summary>
/// Extracts metadata from a FLAC file and wraps its <b>unmodified</b> bytes in an
/// <see cref="AudioBinary"/> tagged <c>.flac</c>. No transcoding — the vault stores the original
/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block.
/// </summary>
public class FlacAudioProcessor
{
private const double FallbackDuration = 180.0;
private const int FallbackBitrate = 1411;
public async Task<AudioBinary?> ProcessFlacFileAsync(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"FLAC file not found: {filePath}");
}
if (!Path.GetExtension(filePath).Equals(".flac", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("File must be a FLAC file", nameof(filePath));
}
var buffer = await File.ReadAllBytesAsync(filePath);
var meta = ExtractFlacMetadata(buffer);
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Extension: ".flac",
Duration: meta.Duration,
Bitrate: meta.Bitrate);
return new AudioBinary(parameters);
}
/// <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.
/// </summary>
private static FlacMetadata ExtractFlacMetadata(byte[] buffer)
{
try
{
// Magic (4) + metadata block header (4) + STREAMINFO data (34) = 42 bytes minimum.
if (buffer.Length < 42)
{
throw new InvalidDataException("File too short for FLAC STREAMINFO");
}
if (buffer[0] != 'f' || buffer[1] != 'L' || buffer[2] != 'a' || buffer[3] != 'C')
{
throw new InvalidDataException("Invalid fLaC magic");
}
// Metadata block header at offset 4: bits 6-0 of byte 0 are the block type (0 = STREAMINFO).
var blockType = buffer[4] & 0x7F;
if (blockType != 0)
{
throw new InvalidDataException($"First metadata block is not STREAMINFO (type {blockType})");
}
// STREAMINFO data begins at offset 8. Layout (bit-packed, big-endian):
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
// bits 3-1 of byte 12: channels - 1
// bit 0 of byte 12 + top 4 bits of byte 13: bits per sample - 1
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
var d = 8;
var sampleRate = (buffer[d + 10] << 12) | (buffer[d + 11] << 4) | (buffer[d + 12] >> 4);
var totalSamples = ((long)(buffer[d + 13] & 0x0F) << 32)
| ((long)buffer[d + 14] << 24)
| ((long)buffer[d + 15] << 16)
| ((long)buffer[d + 16] << 8)
| buffer[d + 17];
if (sampleRate <= 0)
{
throw new InvalidDataException("Invalid FLAC sample rate");
}
var duration = (double)totalSamples / sampleRate;
var bitrate = duration > 0
? (int)(buffer.LongLength * 8L / (duration * 1000))
: FallbackBitrate;
return new FlacMetadata { Duration = duration, Bitrate = bitrate };
}
catch (Exception ex)
{
Console.WriteLine($"Warning: FLAC parsing failed, using defaults: {ex.Message}");
return new FlacMetadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
}
}
private sealed class FlacMetadata
{
public double Duration { get; init; }
public int Bitrate { get; init; }
}
}
@@ -0,0 +1,23 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Strategy for reducing a stream of PCM samples to a fixed-length, peak-normalized loudness
/// envelope. Swappable so the loudness measure (RMS today, LUFS later) can change without
/// touching <c>WaveformProfileService</c>, the stored wire format, or the frontend renderer.
/// </summary>
public interface ILoudnessAlgorithm
{
/// <summary>
/// Computes a peak-normalized loudness profile from raw interleaved PCM.
/// </summary>
/// <param name="pcmData">Interleaved, little-endian PCM sample bytes (the WAV data chunk).</param>
/// <param name="channels">Number of interleaved channels; averaged to mono per sample.</param>
/// <param name="sampleRate">Samples per second (unused by RMS but part of the contract for measures that need it).</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>
/// <returns>
/// A <c>double[bucketCount]</c>, each value in [0, 1], peak-normalized so the loudest bucket
/// 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);
}
@@ -0,0 +1,129 @@
using System.Buffers.Binary;
using DeepDrftContent.FileDatabase.Models;
namespace DeepDrftContent.Processors;
/// <summary>
/// Processes raw image bytes into an <see cref="ImageBinary"/>, mirroring the shape of
/// <see cref="AudioProcessor"/>. Validates the content type resolves to a known image
/// extension, derives the aspect ratio from the image dimensions where cheaply parseable
/// (PNG, JPEG), and defaults to 1.0 for formats whose headers we don't parse.
/// </summary>
/// <remarks>
/// Operates entirely in memory — no disk I/O. Follows the FileDatabase error-handling
/// philosophy: dimension parsing logs a warning and falls back to a best-effort aspect
/// ratio of 1.0 rather than throwing. Content-type rejection is a caller-facing validation
/// failure (returns null), distinct from a parse hiccup.
/// </remarks>
public class ImageProcessor
{
/// <summary>
/// Builds an <see cref="ImageBinary"/> from raw image bytes and a MIME content type.
/// Returns null when the content type does not resolve to a recognised image extension
/// (the <c>.bin</c> sentinel from <see cref="MimeTypeExtensions.GetExtension"/>).
/// </summary>
public ImageBinary? Process(byte[] imageBytes, string contentType)
{
var extension = MimeTypeExtensions.GetExtension(contentType);
if (extension == ".bin")
{
Console.WriteLine($"Warning: ImageProcessor rejected unsupported content type '{contentType}'");
return null;
}
var aspectRatio = ComputeAspectRatio(imageBytes, extension);
var parameters = new ImageBinaryParams(
Buffer: imageBytes,
Size: imageBytes.Length,
Extension: extension,
AspectRatio: aspectRatio);
return new ImageBinary(parameters);
}
/// <summary>
/// Derives width/height from the format header and returns width/height. Defaults to 1.0
/// for unparsed formats (gif, webp, bmp, svg) and on any parse failure.
/// </summary>
private static double ComputeAspectRatio(byte[] bytes, string extension)
{
try
{
return extension switch
{
".png" => ParsePngAspectRatio(bytes),
".jpg" or ".jpeg" => ParseJpegAspectRatio(bytes),
_ => 1.0,
};
}
catch (Exception ex)
{
Console.WriteLine($"Warning: image dimension parsing failed for '{extension}', defaulting aspect ratio to 1.0: {ex.Message}");
return 1.0;
}
}
/// <summary>
/// PNG: the IHDR chunk places width at bytes 1619 and height at 2023, both big-endian
/// uint32. Guards on the "PNG" signature at bytes 13.
/// </summary>
private static double ParsePngAspectRatio(byte[] bytes)
{
if (bytes.Length < 24 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G')
{
return 1.0;
}
var width = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(16, 4));
var height = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(20, 4));
return Ratio(width, height);
}
/// <summary>
/// JPEG: walk the marker segments from byte 2 looking for SOF0 (0xFF 0xC0) or SOF2
/// (0xFF 0xC2). Height is a big-endian uint16 at marker+5, width at marker+7. Guards on
/// the SOI marker (0xFF 0xD8) at bytes 01.
/// </summary>
private static double ParseJpegAspectRatio(byte[] bytes)
{
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
{
return 1.0;
}
var pos = 2;
while (pos + 9 < bytes.Length)
{
// Marker segments begin with 0xFF; skip any fill bytes before the marker id.
if (bytes[pos] != 0xFF)
{
pos++;
continue;
}
var marker = bytes[pos + 1];
if (marker == 0xC0 || marker == 0xC2)
{
var height = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 5, 2));
var width = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 7, 2));
return Ratio(width, height);
}
// Standalone markers (RSTn, SOI, EOI, TEM) carry no length payload; everything
// else has a 2-byte big-endian segment length immediately after the marker id.
if (marker is 0xD8 or 0xD9 or 0x01 || (marker >= 0xD0 && marker <= 0xD7))
{
pos += 2;
continue;
}
var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 2, 2));
pos += 2 + segmentLength;
}
return 1.0;
}
private static double Ratio(uint width, uint height) => height == 0 ? 1.0 : (double)width / height;
}
@@ -0,0 +1,312 @@
using DeepDrftContent.FileDatabase.Models;
namespace DeepDrftContent.Processors;
/// <summary>
/// Extracts metadata from an MP3 file and wraps its <b>unmodified</b> bytes in an
/// <see cref="AudioBinary"/> tagged <c>.mp3</c>. No transcoding — the vault stores the original
/// stream; only duration/bitrate metadata are computed from the first MPEG frame header (plus a
/// Xing/VBRI tag when present for accurate VBR duration).
/// </summary>
public class Mp3AudioProcessor
{
// MPEG1 Layer III bitrate table (kbps), indexed by the 4-bit bitrate index. 0 = free, 15 = bad.
private static readonly int[] Mpeg1Layer3Bitrates =
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
// MPEG2/2.5 Layer III bitrate table (kbps), indexed by 4-bit bitrate index. 0 = free, 15 = bad.
private static readonly int[] Mpeg2Layer3Bitrates =
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
private static readonly int[] Mpeg1SampleRates = [44100, 48000, 32000];
private static readonly int[] Mpeg2SampleRates = [22050, 24000, 16000];
private static readonly int[] Mpeg25SampleRates = [11025, 12000, 8000];
private const double FallbackDuration = 180.0;
private const int FallbackBitrate = 320;
public async Task<AudioBinary?> ProcessMp3FileAsync(string filePath)
{
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"MP3 file not found: {filePath}");
}
if (!Path.GetExtension(filePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("File must be an MP3 file", nameof(filePath));
}
var buffer = await File.ReadAllBytesAsync(filePath);
var meta = ExtractMp3Metadata(buffer);
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Extension: ".mp3",
Duration: meta.Duration,
Bitrate: meta.Bitrate);
return new AudioBinary(parameters);
}
/// <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.
/// </summary>
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer)
{
try
{
var frameStart = FindFirstFrame(buffer);
if (frameStart < 0)
{
throw new InvalidDataException("No valid MPEG frame sync found");
}
var header = DecodeFrameHeader(buffer, frameStart);
var duration = ComputeDuration(buffer, frameStart, header);
return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps };
}
catch (Exception ex)
{
Console.WriteLine($"Warning: MP3 parsing failed, using defaults: {ex.Message}");
return new Mp3Metadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
}
}
/// <summary>
/// Returns the offset of the first valid MPEG frame, skipping a leading ID3v2 tag if present.
/// Scans for a 0xFF / 0xE0-syncword pair and fully validates the 4-byte header before accepting.
/// </summary>
private static int FindFirstFrame(byte[] buffer)
{
var start = SkipId3v2(buffer);
for (int i = start; i < buffer.Length - 4; i++)
{
if (buffer[i] != 0xFF || (buffer[i + 1] & 0xE0) != 0xE0)
{
continue;
}
if (IsValidFrameHeader(buffer, i))
{
return i;
}
}
return -1;
}
/// <summary>
/// Returns the byte offset just past an ID3v2 tag, or 0 if none. The tag size is a syncsafe
/// big-endian uint28 at bytes 69 (each byte's MSB is 0). A footer (flag bit 4 of byte 5) adds 10.
/// </summary>
private static int SkipId3v2(byte[] buffer)
{
if (buffer.Length < 10 || buffer[0] != 'I' || buffer[1] != 'D' || buffer[2] != '3')
{
return 0;
}
var size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
var skip = 10 + size;
if ((buffer[5] & 0x10) != 0)
{
skip += 10; // footer present
}
return skip <= buffer.Length ? skip : 0;
}
/// <summary>
/// Fully validates a candidate 4-byte frame header: layer must be III, and version, bitrate
/// index, and sample-rate index must all be non-reserved (rejects free bitrate, bad index 0xF,
/// and reserved sample rate 3).
/// </summary>
private static bool IsValidFrameHeader(byte[] buffer, int pos)
{
var b1 = buffer[pos + 1];
var b2 = buffer[pos + 2];
var versionBits = (b1 >> 3) & 0x03;
if (versionBits == 1) // 1 = reserved
{
return false;
}
var layerBits = (b1 >> 1) & 0x03;
if (layerBits != 1) // 1 = Layer III; this processor handles Layer III only
{
return false;
}
var bitrateIndex = (b2 >> 4) & 0x0F;
if (bitrateIndex == 0 || bitrateIndex == 0x0F) // 0 = free, 0xF = bad
{
return false;
}
var sampleRateIndex = (b2 >> 2) & 0x03;
if (sampleRateIndex == 3) // reserved
{
return false;
}
return true;
}
private static FrameHeader DecodeFrameHeader(byte[] buffer, int pos)
{
var b1 = buffer[pos + 1];
var b2 = buffer[pos + 2];
var b3 = buffer[pos + 3];
var versionBits = (b1 >> 3) & 0x03;
var version = versionBits switch
{
3 => MpegVersion.Mpeg1,
2 => MpegVersion.Mpeg2,
_ => MpegVersion.Mpeg25, // 0 = MPEG2.5
};
var bitrateIndex = (b2 >> 4) & 0x0F;
var bitrateTable = version == MpegVersion.Mpeg1 ? Mpeg1Layer3Bitrates : Mpeg2Layer3Bitrates;
var bitrateKbps = bitrateTable[bitrateIndex];
var sampleRateIndex = (b2 >> 2) & 0x03;
var sampleRate = version switch
{
MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex],
MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex],
_ => Mpeg25SampleRates[sampleRateIndex],
};
var channelMode = (b3 >> 6) & 0x03;
var channels = channelMode == 3 ? 1 : 2;
var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576;
return new FrameHeader
{
Version = version,
BitrateKbps = bitrateKbps,
SampleRate = sampleRate,
Channels = channels,
SamplesPerFrame = samplesPerFrame,
};
}
/// <summary>
/// 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)
{
var xingFrames = ReadXingFrameCount(buffer, frameStart, header);
if (xingFrames > 0 && header.SampleRate > 0)
{
return (double)xingFrames * header.SamplesPerFrame / header.SampleRate;
}
var vbriFrames = ReadVbriFrameCount(buffer, frameStart);
if (vbriFrames > 0 && header.SampleRate > 0)
{
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.
var bytesPerSecond = header.BitrateKbps * 125;
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
}
/// <summary>
/// Reads the Xing/Info VBR total-frame count from the side-information region of the first frame,
/// or 0 if no Xing tag or no frame-count flag. Side-info offset depends on version and channels.
/// </summary>
private static int ReadXingFrameCount(byte[] buffer, int frameStart, FrameHeader header)
{
var sideInfoSize = header.Version == MpegVersion.Mpeg1
? (header.Channels == 1 ? 17 : 32)
: (header.Channels == 1 ? 9 : 17);
var tagPos = frameStart + 4 + sideInfoSize;
if (tagPos + 12 > buffer.Length)
{
return 0;
}
if (!MatchesAscii(buffer, tagPos, "Xing") && !MatchesAscii(buffer, tagPos, "Info"))
{
return 0;
}
var flags = ReadUInt32BigEndian(buffer, tagPos + 4);
if ((flags & 0x01) == 0) // bit 0 = frame-count present
{
return 0;
}
return (int)ReadUInt32BigEndian(buffer, tagPos + 8);
}
/// <summary>
/// Reads the Fraunhofer VBRI total-frame count. The VBRI tag sits at a fixed offset 32 past the
/// frame header (frameStart + 4 + 32); the frame count is a big-endian uint32 at tag offset 14.
/// </summary>
private static int ReadVbriFrameCount(byte[] buffer, int frameStart)
{
var tagPos = frameStart + 4 + 32;
if (tagPos + 18 > buffer.Length)
{
return 0;
}
if (!MatchesAscii(buffer, tagPos, "VBRI"))
{
return 0;
}
return (int)ReadUInt32BigEndian(buffer, tagPos + 14);
}
private static bool MatchesAscii(byte[] buffer, int pos, string tag)
{
for (int i = 0; i < tag.Length; i++)
{
if (buffer[pos + i] != (byte)tag[i])
{
return false;
}
}
return true;
}
private static uint ReadUInt32BigEndian(byte[] buffer, int pos) =>
((uint)buffer[pos] << 24) | ((uint)buffer[pos + 1] << 16) | ((uint)buffer[pos + 2] << 8) | buffer[pos + 3];
private enum MpegVersion
{
Mpeg1,
Mpeg2,
Mpeg25,
}
private sealed class FrameHeader
{
public MpegVersion Version { get; init; }
public int BitrateKbps { get; init; }
public int SampleRate { get; init; }
public int Channels { get; init; }
public int SamplesPerFrame { get; init; }
}
private sealed class Mp3Metadata
{
public double Duration { get; init; }
public int Bitrate { get; init; }
}
}
@@ -0,0 +1,138 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
/// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{
public double[] Compute(ReadOnlySpan<byte> pcmData, 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]++;
}
var peak = 0.0;
for (var i = 0; i < bucketCount; i++)
{
if (counts[i] > 0)
{
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
if (result[i] > peak)
{
peak = result[i];
}
}
}
if (peak <= 0)
{
// Silence — return all zeros (Array is already zero-initialized).
Array.Clear(result);
return result;
}
for (var i = 0; i < bucketCount; i++)
{
result[i] /= peak;
}
return result;
}
/// <summary>
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
/// </summary>
private static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
{
switch (bitsPerSample)
{
case 8:
// Unsigned, midpoint 128.
return (data[offset] - 128) / 128.0;
case 16:
{
short sample = (short)(data[offset] | (data[offset + 1] << 8));
return sample / 32768.0;
}
case 24:
{
// Sign-extend the 24-bit little-endian value into an int.
int raw = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
if ((raw & 0x800000) != 0)
{
raw |= unchecked((int)0xFF000000);
}
return raw / 8388608.0;
}
case 32:
{
int sample = data[offset]
| (data[offset + 1] << 8)
| (data[offset + 2] << 16)
| (data[offset + 3] << 24);
return sample / 2147483648.0;
}
default:
throw new ArgumentOutOfRangeException(
nameof(bitsPerSample), bitsPerSample, "Unsupported PCM bit depth.");
}
}
}
@@ -0,0 +1,11 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Configuration for waveform loudness profiling. <see cref="BucketCount"/> is the stored
/// resolution — the number of loudness buckets computed and persisted per track, which is also
/// the bar count the frontend WaveformSeeker renders.
/// </summary>
public class WaveformProfileOptions
{
public int BucketCount { get; set; } = 512;
}
@@ -0,0 +1,123 @@
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftContent.Processors;
/// <summary>
/// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar
/// in the <see cref="VaultConstants.WaveformProfiles"/> vault, keyed by the track's EntryKey.
/// The profile is the upload-time, off-the-playback-path representation the frontend fetches to
/// render the WaveformSeeker. The loudness measure is injected (<see cref="ILoudnessAlgorithm"/>)
/// so it can be swapped without changing storage or the wire format.
/// </summary>
public class WaveformProfileService
{
private const string ProfileExtension = ".wfp";
private readonly FileDb _fileDatabase;
private readonly AudioProcessor _audioProcessor;
private readonly ILoudnessAlgorithm _loudnessAlgorithm;
private readonly WaveformProfileOptions _options;
private readonly ILogger<WaveformProfileService> _logger;
public WaveformProfileService(
FileDb fileDatabase,
AudioProcessor audioProcessor,
ILoudnessAlgorithm loudnessAlgorithm,
IOptions<WaveformProfileOptions> options,
ILogger<WaveformProfileService> logger)
{
_fileDatabase = fileDatabase;
_audioProcessor = audioProcessor;
_loudnessAlgorithm = loudnessAlgorithm;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
/// <paramref name="entryKey"/>. Returns false (and logs) on any failure — a missing profile
/// is handled gracefully downstream, so callers on the upload path log-and-continue rather
/// than failing the upload. Does not throw for expected failure modes.
/// </summary>
public async Task<bool> ComputeAndStoreAsync(ReadOnlyMemory<byte> wavBytes, string entryKey)
{
try
{
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
if (pcm is null)
{
_logger.LogWarning(
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.",
entryKey);
return false;
}
var value = pcm.Value;
var profile = _loudnessAlgorithm.Compute(
value.Pcm.Span,
value.Channels,
value.SampleRate,
value.BitsPerSample,
_options.BucketCount);
var quantized = Quantize(profile);
await EnsureVaultAsync();
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync(
VaultConstants.WaveformProfiles, entryKey, binary);
if (!stored)
{
_logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey);
return false;
}
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey);
return false;
}
}
/// <summary>
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored
/// (existing tracks predate profiling, and computation may have failed). Each byte is a
/// peak-normalized loudness value in [0, 255].
/// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey)
{
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
VaultConstants.WaveformProfiles, entryKey);
return binary?.Buffer;
}
/// <summary>
/// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a
/// truncating cast keeps every in-range value within a byte without a clamp branch.
/// </summary>
private static byte[] Quantize(double[] profile)
{
var bytes = new byte[profile.Length];
for (var i = 0; i < profile.Length; i++)
{
bytes[i] = (byte)(profile[i] * 255);
}
return bytes;
}
private async Task EnsureVaultAsync()
{
if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media);
}
}
}
+34 -17
View File
@@ -12,39 +12,43 @@ namespace DeepDrftContent;
public class TrackContentService
{
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
private readonly AudioProcessor _audioProcessor;
private readonly AudioProcessorRouter _audioProcessorRouter;
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor)
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter)
{
_fileDatabase = fileDatabase;
_audioProcessor = audioProcessor;
_audioProcessorRouter = audioProcessorRouter;
}
/// <summary>
/// Adds a new track from a WAV file to both databases
/// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The
/// router selects the processor by extension; original bytes are stored for mp3/flac (no
/// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time.
/// </summary>
/// <param name="wavFilePath">Path to the WAV file</param>
/// <param name="audioFilePath">Path to the audio file</param>
/// <param name="trackName">Name of the track</param>
/// <param name="artist">Artist name</param>
/// <param name="album">Optional album name</param>
/// <param name="genre">Optional genre</param>
/// <param name="releaseDate">Optional release date</param>
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
/// <returns>The track entity with generated ID and media path</returns>
public async Task<TrackEntity?> AddTrackFromWavAsync(
string wavFilePath,
public async Task<TrackEntity?> AddTrackAsync(
string audioFilePath,
string trackName,
string artist,
string? album = null,
string? genre = null,
DateOnly? releaseDate = null)
DateOnly? releaseDate = null,
string? originalFileName = null)
{
try
{
// Process the WAV file
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
// Process the audio file (routed by extension)
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
{
throw new InvalidOperationException("Failed to process WAV file");
throw new InvalidOperationException("Failed to process audio file");
}
// Generate a unique track ID
@@ -63,26 +67,39 @@ public class TrackContentService
throw new InvalidOperationException("Failed to store audio in FileDatabase");
}
// Create the track entity for SQL database
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
var trackEntity = new TrackEntity
{
EntryKey = trackId, // FileDatabase entry ID
TrackName = trackName,
Artist = artist,
Album = album,
Genre = genre,
ReleaseDate = releaseDate
OriginalFileName = originalFileName
};
return trackEntity;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"TrackContentService.AddTrackFromWavAsync failed: {ex.Message}");
Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
/// </summary>
public Task<TrackEntity?> AddTrackFromWavAsync(
string wavFilePath,
string trackName,
string artist,
string? album = null,
string? genre = null,
DateOnly? releaseDate = null,
string? originalFileName = null) =>
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
/// <summary>
/// Retrieves audio binary from FileDatabase
/// </summary>
@@ -0,0 +1,67 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
{
public override void Configure(EntityTypeBuilder<ReleaseEntity> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("release");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.Title)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("title");
builder.Property(e => e.Artist)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("artist");
builder.Property(e => e.Genre)
.HasMaxLength(100)
.HasColumnName("genre");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
builder.Property(e => e.ImagePath)
.HasMaxLength(500)
.HasColumnName("image_path");
builder.Property(e => e.ReleaseType)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
.HasMaxLength(20)
.HasColumnName("release_type")
.HasDefaultValue(ReleaseType.Single);
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_release_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted");
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
// resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row.
builder.HasIndex(e => new { e.Title, e.Artist })
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
}
}
@@ -30,28 +30,24 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
.HasMaxLength(200)
.HasColumnName("track_name");
builder.Property(e => e.Artist)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("artist");
builder.Property(e => e.Album)
.HasMaxLength(200)
.HasColumnName("album");
builder.Property(e => e.Genre)
.HasMaxLength(100)
.HasColumnName("genre");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
builder.Property(e => e.ImagePath)
builder.Property(e => e.OriginalFileName)
.HasMaxLength(500)
.HasColumnName("image_path");
.HasColumnName("original_file_name");
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
builder.Property(e => e.TrackNumber)
.IsRequired()
.HasColumnName("track_number")
.HasDefaultValue(1);
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
// Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its
// tracks intact as loose tracks rather than cascading them away.
builder.HasOne(e => e.Release)
.WithMany(r => r.Tracks)
.HasForeignKey(e => e.ReleaseId)
.OnDelete(DeleteBehavior.SetNull);
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
+2
View File
@@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext
}
public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
}
}
+15 -16
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NetBlocks.Utilities.Environment;
namespace DeepDrftData.Data;
@@ -7,23 +8,21 @@ public class DeepDrftContextFactory : IDesignTimeDbContextFactory<DeepDrftContex
{
public DeepDrftContext CreateDbContext(string[] args)
{
// Load the real connection string from environment/connections.json — the same
// file DeepDrftPublic's Program.cs loads via CredentialTools. When EF tools run with
// --startup-project DeepDrftPublic, the working directory resolves there, so this
// relative path works without any env var configuration.
const string relPath = "environment/connections.json";
if (!File.Exists(relPath))
throw new FileNotFoundException(
$"'{relPath}' not found. Run EF commands with --startup-project DeepDrftPublic " +
$"from the solution root (current dir: {Directory.GetCurrentDirectory()}).", relPath);
var path = CredentialTools.ResolvePath("connections", "environment/connections.json");
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(relPath));
var connectionString = doc.RootElement
.GetProperty("ConnectionStrings")
.GetProperty("DefaultConnection")
.GetString()
?? throw new InvalidOperationException(
"ConnectionStrings:DefaultConnection not found in environment/connections.json");
string? connectionString = null;
if (File.Exists(path))
{
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path));
connectionString = doc.RootElement
.GetProperty("ConnectionStrings")
.GetProperty("DefaultConnection")
.GetString();
}
// Fall back to a design-time dummy — the bundle only needs the provider/schema,
// not a live connection. This removes the requirement to write a dummy file in CI.
connectionString ??= "Host=localhost;Database=deepdrft-design-time;Username=dummy";
var optionsBuilder = new DbContextOptionsBuilder<DeepDrftContext>();
optionsBuilder.UseNpgsql(connectionString);
+1
View File
@@ -18,6 +18,7 @@
</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" />
</ItemGroup>
+32 -1
View File
@@ -12,9 +12,40 @@ namespace DeepDrftData;
public interface ITrackService
{
Task<ResultContainer<TrackDto?>> GetById(long id);
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey);
/// <summary>
/// Returns a single track chosen uniformly at random, or null when the library is empty
/// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature.
/// </summary>
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
Task<ResultContainer<List<TrackDto>>> GetAll();
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
/// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id);
/// <summary>Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op.</summary>
Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default);
/// <summary>
/// Count of non-deleted tracks on a release. Backs the delete-cascade decision: when a track
/// delete leaves a release with zero live tracks, the release is soft-deleted too.
/// </summary>
Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,107 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260607124422_AddOriginalFileName")]
partial class AddOriginalFileName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.ToTable("track", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddOriginalFileName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "original_file_name",
table: "track",
type: "character varying(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "original_file_name",
table: "track");
}
}
}
@@ -0,0 +1,121 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260611005700_AddReleaseTypeAndTrackNumber")]
partial class AddReleaseTypeAndTrackNumber
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.ToTable("track", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseTypeAndTrackNumber : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "release_type",
table: "track",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Single");
migrationBuilder.AddColumn<int>(
name: "track_number",
table: "track",
type: "integer",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "release_type",
table: "track");
migrationBuilder.DropColumn(
name: "track_number",
table: "track");
}
}
}
@@ -0,0 +1,174 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260611164537_NormalizeReleaseTrack")]
partial class NormalizeReleaseTrack
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class NormalizeReleaseTrack : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Create the release table.
migrationBuilder.CreateTable(
name: "release",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
release_date = table.Column<DateOnly>(type: "date", nullable: true),
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_release", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_release_is_deleted",
table: "release",
column: "is_deleted");
// 2. Add the nullable FK column to track. A fresh column (not a rename of
// created_by_user_id) so existing rows start with a null release until back-filled.
migrationBuilder.AddColumn<long>(
name: "release_id",
table: "track",
type: "bigint",
nullable: true);
// 3. Data migration — must run after the release table exists and release_id is added,
// and before the release-cardinal columns are dropped from track (the SELECT reads them).
// Create one release row per distinct (album, artist) from existing tracks, carrying
// the release-cardinal fields. Tracks with a null album remain release_id = null.
migrationBuilder.Sql(@"
INSERT INTO release (title, artist, genre, release_date, image_path, release_type,
created_by_user_id, created_at, updated_at, is_deleted)
SELECT DISTINCT ON (album, artist)
album, artist, genre, release_date, image_path, release_type,
created_by_user_id, NOW(), NOW(), false
FROM track
WHERE album IS NOT NULL
ORDER BY album, artist, id;
");
// Back-fill the FK: match each track to the release created from its (album, artist).
migrationBuilder.Sql(@"
UPDATE track
SET release_id = r.id
FROM release r
WHERE track.album = r.title
AND track.artist = r.artist;
");
// 4. Index + FK now that the column carries its back-filled values.
migrationBuilder.CreateIndex(
name: "IX_track_release_id",
table: "track",
column: "release_id");
migrationBuilder.AddForeignKey(
name: "FK_track_release_release_id",
table: "track",
column: "release_id",
principalTable: "release",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
// 5. Drop the now-migrated release-cardinal columns from track.
migrationBuilder.DropColumn(name: "album", table: "track");
migrationBuilder.DropColumn(name: "artist", table: "track");
migrationBuilder.DropColumn(name: "genre", table: "track");
migrationBuilder.DropColumn(name: "image_path", table: "track");
migrationBuilder.DropColumn(name: "release_date", table: "track");
migrationBuilder.DropColumn(name: "release_type", table: "track");
migrationBuilder.DropColumn(name: "created_by_user_id", table: "track");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so
// the add succeeds against existing rows before the back-fill repopulates it.
migrationBuilder.AddColumn<string>(
name: "album",
table: "track",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "artist",
table: "track",
type: "character varying(200)",
maxLength: 200,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "genre",
table: "track",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "image_path",
table: "track",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "release_date",
table: "track",
type: "date",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "release_type",
table: "track",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Single");
migrationBuilder.AddColumn<long>(
name: "created_by_user_id",
table: "track",
type: "bigint",
nullable: true);
// 2. Re-populate the track columns from the release join before the release table and FK go.
migrationBuilder.Sql(@"
UPDATE track
SET artist = r.artist,
album = r.title,
genre = r.genre,
release_date = r.release_date,
image_path = r.image_path,
release_type = r.release_type,
created_by_user_id = r.created_by_user_id
FROM release r
WHERE track.release_id = r.id;
");
// 3. Drop the FK, index, the release_id column, and the release table.
migrationBuilder.DropForeignKey(
name: "FK_track_release_release_id",
table: "track");
migrationBuilder.DropIndex(
name: "IX_track_release_id",
table: "track");
migrationBuilder.DropColumn(
name: "release_id",
table: "track");
migrationBuilder.DropTable(
name: "release");
}
}
}
@@ -0,0 +1,178 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260611184732_AddReleaseUniqueTitleArtist")]
partial class AddReleaseUniqueTitleArtist
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseUniqueTitleArtist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_release_title_artist",
table: "release",
columns: new[] { "title", "artist" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_title_artist",
table: "release");
}
}
}
@@ -0,0 +1,178 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260612000000_SoftDeleteOrphanedReleases")]
partial class SoftDeleteOrphanedReleases
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
// Data-only migration: no schema change, snapshot unchanged.
public partial class SoftDeleteOrphanedReleases : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Backfill: soft-delete any live release whose tracks were all soft-deleted before the
// delete-cascade in UnifiedTrackService existed. These show as 0-track rows in the albums
// browser; this clears the pre-existing orphans the cascade now prevents going forward.
migrationBuilder.Sql(@"
UPDATE release
SET is_deleted = true,
updated_at = now()
WHERE id IN (
SELECT r.id
FROM release r
WHERE r.is_deleted = false
AND NOT EXISTS (
SELECT 1
FROM track t
WHERE t.release_id = r.id
AND t.is_deleted = false
)
);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back");
}
}
}
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
@@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
@@ -76,12 +65,82 @@ namespace DeepDrftData.Migrations
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -91,8 +150,25 @@ namespace DeepDrftData.Migrations
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
+186 -6
View File
@@ -1,31 +1,211 @@
using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
namespace DeepDrftData.Repositories;
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
{
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
// to the injected context rather than reaching for a service locator — it is the same scoped
// instance the base holds, so reads/writes stay in one unit of work.
private readonly DeepDrftContext _context;
public TrackRepository(
DeepDrftContext context,
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
IDbExceptionClassifier? classifier = null)
: base(context, logger, classifier: classifier)
{
_context = context;
}
// Override base GetByIdAsync to include the Release navigation. Without this, the base
// Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
public override async Task<TrackEntity?> GetByIdAsync(long id)
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);
// Override base GetAllAsync for the same reason — include Release so callers (e.g.
// TrackManager.GetAll) receive fully-populated entities without a separate query.
public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
=> await Query.Include(t => t.Release).ToListAsync();
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
// converter can project the release-cardinal fields.
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
// is small today but this keeps the cost flat as it grows. Returns null when empty so the
// service surfaces a valid empty-library state, not an error. Uses Query (soft-delete
// filtered) so deleted tracks are never candidates.
public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
{
var count = await Query.CountAsync(cancellationToken);
if (count == 0)
return null;
var index = Random.Shared.Next(count);
return await Query
.Include(t => t.Release)
.OrderBy(t => t.Id)
.Skip(index)
.Take(1)
.FirstOrDefaultAsync(cancellationToken);
}
// Paged query with optional filter predicates. Built off Query (soft-delete filtered) rather than the
// base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and
// direction ride in on the PagingParameters the manager already built, so sort + filter +
// pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects
// the filtered set.
public async Task<PagedResult<TrackEntity>> GetPagedFilteredAsync(
PagingParameters<TrackEntity> paging,
TrackFilter? filter,
CancellationToken ct = default)
{
// Include Release so both the filter predicates and the converter can read release-cardinal
// fields through the navigation.
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
if (filter is not null)
{
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
// Release, which is null for loose tracks — guard the navigation before ILike.
var pattern = $"%{filter.SearchText}%";
query = query.Where(t =>
EF.Functions.ILike(t.TrackName, pattern)
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
}
if (!string.IsNullOrWhiteSpace(filter.Album))
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
}
var totalCount = await query.CountAsync(ct);
if (paging.OrderBy is not null)
{
query = paging.IsDescending
? query.OrderByDescending(paging.OrderBy)
: query.OrderBy(paging.OrderBy);
}
var items = await query
.Skip(paging.Skip)
.Take(paging.PageSize)
.ToListAsync(ct);
return new PagedResult<TrackEntity>
{
Items = items,
TotalCount = totalCount,
Page = paging.Page,
PageSize = paging.PageSize,
};
}
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.Where(r => !r.IsDeleted)
.OrderBy(r => r.Title)
.ToListAsync(ct);
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
// (no release) carry no genre and are excluded.
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
=> await Query
.Where(t => t.Release != null && t.Release.Genre != null)
.GroupBy(t => t.Release!.Genre!)
.Select(g => new GenreSummaryDto
{
Genre = g.Key,
TrackCount = g.Count(),
})
.OrderBy(g => g.Genre)
.ToListAsync(ct);
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
=> await Query
.Where(t => t.ReleaseId != null)
.GroupBy(t => t.ReleaseId!.Value)
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
// signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
string title, string artist, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
// because the repository owns the DbContext — the manager stays free of direct context access.
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
{
_context.Set<ReleaseEntity>().Add(release);
await _context.SaveChangesAsync(ct);
return release;
}
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
// null when the id does not resolve (or the release is soft-deleted).
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
=> await _context.Set<ReleaseEntity>()
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
{
_context.Set<ReleaseEntity>().Update(release);
await _context.SaveChangesAsync(ct);
}
// Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted
// guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row.
public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default)
{
await _context.Set<ReleaseEntity>()
.Where(r => r.Id == id && !r.IsDeleted)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.IsDeleted, true)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct);
}
// Count of non-deleted tracks on a single release. Backs the delete-cascade decision in
// UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too.
// Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count.
public async Task<int> CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default)
=> await Query.CountAsync(t => t.ReleaseId == releaseId, ct);
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
target.EntryKey = source.EntryKey;
target.TrackName = source.TrackName;
target.Artist = source.Artist;
target.Album = source.Album;
target.Genre = source.Genre;
target.ReleaseDate = source.ReleaseDate;
target.ImagePath = source.ImagePath;
target.CreatedByUserId = source.CreatedByUserId;
target.TrackNumber = source.TrackNumber;
target.OriginalFileName = source.OriginalFileName;
target.ReleaseId = source.ReleaseId;
}
}
+41 -12
View File
@@ -9,9 +9,40 @@ namespace DeepDrftData;
/// The DTO side mirrors the entity field-for-field; the audit columns
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
///
/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable
/// ReleaseId/Release. The release-cardinal data converts through the Release maps below.
/// </summary>
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
{
public static ReleaseDto Convert(ReleaseEntity entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Title = entity.Title,
Artist = entity.Artist,
Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
ReleaseType = entity.ReleaseType,
CreatedByUserId = entity.CreatedByUserId
};
public static ReleaseEntity Convert(ReleaseDto dto) => new()
{
Id = dto.Id,
CreatedAt = dto.CreatedAt,
UpdatedAt = dto.UpdatedAt,
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath,
ReleaseType = dto.ReleaseType,
CreatedByUserId = dto.CreatedByUserId
};
public static TrackDto Convert(TrackEntity entity) => new()
{
Id = entity.Id,
@@ -19,14 +50,15 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
UpdatedAt = entity.UpdatedAt,
EntryKey = entity.EntryKey,
TrackName = entity.TrackName,
Artist = entity.Artist,
Album = entity.Album,
Genre = entity.Genre,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
CreatedByUserId = entity.CreatedByUserId
OriginalFileName = entity.OriginalFileName,
TrackNumber = entity.TrackNumber,
ReleaseId = entity.ReleaseId,
Release = entity.Release is null ? null : Convert(entity.Release)
};
// DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left
// unset: the manager resolves/attaches the release row against the tracked context so a detached
// graph never overwrites a shared release record.
public static TrackEntity Convert(TrackDto model) => new()
{
Id = model.Id,
@@ -34,11 +66,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
UpdatedAt = model.UpdatedAt,
EntryKey = model.EntryKey,
TrackName = model.TrackName,
Artist = model.Artist,
Album = model.Album,
Genre = model.Genre,
ReleaseDate = model.ReleaseDate,
ImagePath = model.ImagePath,
CreatedByUserId = model.CreatedByUserId
OriginalFileName = model.OriginalFileName,
TrackNumber = model.TrackNumber,
ReleaseId = model.ReleaseId
};
}
+186 -6
View File
@@ -1,3 +1,4 @@
using Data.Errors;
using Data.Managers;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
@@ -46,6 +47,38 @@ public class TrackManager
}
}
// Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain
// public method. Mirrors the nullable-on-miss shape of ITrackService.GetById.
public async Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
{
try
{
var entity = await Repository.GetByEntryKeyAsync(entryKey);
return ResultContainer<TrackDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
}
}
// No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty
// shape of GetById: pass with null when the library has no tracks.
public async Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default)
{
try
{
var entity = await Repository.GetRandomAsync(cancellationToken);
return ResultContainer<TrackDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetAll()
{
try
@@ -65,6 +98,7 @@ public class TrackManager
int pageSize,
string? sortColumn,
bool sortDescending,
TrackFilter? filter = null,
CancellationToken cancellationToken = default)
{
try
@@ -74,18 +108,28 @@ public class TrackManager
Page = pageNumber,
PageSize = pageSize,
IsDescending = sortDescending,
// Sorts navigate through the nullable Release relation; the null-coalescing
// sentinels push loose tracks (no release) to the end, matching the prior
// nulls-last behaviour on the flat columns.
OrderBy = sortColumn switch
{
"TrackName" => e => e.TrackName,
"Artist" => e => e.Artist,
"Album" => e => (object)(e.Album ?? string.Empty),
"Genre" => e => (object)(e.Genre ?? string.Empty),
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
_ => e => e.Id
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
"TrackNumber" => e => e.TrackNumber,
_ => e => e.Id
}
};
var page = await Repository.GetPagedAsync(parameters);
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
// all Where predicates, and it always includes Release. This removes the base-class
// GetPagedAsync path, which has no .Include and would return entities with null Release.
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
}
@@ -95,10 +139,100 @@ public class TrackManager
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
{
try
{
var releases = await Repository.GetReleasesAsync(cancellationToken);
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
var dtos = releases
.Select(r =>
{
var dto = TrackConverter.Convert(r);
dto.TrackCount = counts.GetValueOrDefault(r.Id);
return dto;
})
.ToList();
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
}
catch (Exception e)
{
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData);
entity.Id = 0;
entity.Title = title;
entity.Artist = artist;
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row. Should not return null here since the
// constraint just fired, but re-throw if it does so the caller sees an error.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
}
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
{
try
{
var genres = await Repository.GetDistinctGenresAsync(cancellationToken);
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
}
catch (Exception e)
{
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{
try
{
// A track with release context resolves (or creates) the shared release first so the FK
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
// a populated ReleaseId, which falls straight through.
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
if (!resolved.Success || resolved.Value is null)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
}
@@ -115,6 +249,26 @@ public class TrackManager
try
{
await Repository.UpdateAsync(TrackConverter.Convert(track));
// Release-cardinal edits flow through the linked release row, not the track. When the
// track carries a Release payload and a resolved FK, load the tracked release, apply the
// edited fields, and save. EntryKey/track fields are already persisted above.
if (track.Release is { } release && track.ReleaseId is { } releaseId)
{
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
if (releaseEntity is not null)
{
releaseEntity.Title = release.Title;
releaseEntity.Artist = release.Artist;
releaseEntity.Genre = release.Genre;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.ReleaseType = release.ReleaseType;
releaseEntity.CreatedByUserId = release.CreatedByUserId;
await Repository.UpdateReleaseAsync(releaseEntity);
}
}
var updated = await Repository.GetByIdAsync(track.Id);
return updated is not null
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
@@ -128,4 +282,30 @@ public class TrackManager
// Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete
// by signature. No override.
public async Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default)
{
try
{
await Repository.SoftDeleteReleaseAsync(id, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
return Result.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
{
try
{
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
}
+1
View File
@@ -9,6 +9,7 @@
<DeepDrftFontLinks />
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
<link rel="stylesheet" href="@Assets["DeepDrftManager.styles.css"]" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
<ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
+117 -3
View File
@@ -1,11 +1,125 @@
@page "/"
@using DeepDrftManager.Services
@attribute [Authorize]
@layout Layout.CmsLayout
@inject NavigationManager Nav
@inject ICmsTrackService CmsTrackService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
<MudGrid Spacing="4">
<MudItem xs="12" sm="4">
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
</MudItem>
<MudItem xs="12" sm="4">
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
</MudItem>
</MudGrid>
</MudContainer>
@code {
private bool _tracksLoading = true;
private bool _albumsLoading = true;
private bool _genresLoading = true;
private int? _trackCount;
private int? _albumCount;
private int? _genreCount;
protected override async Task OnInitializedAsync()
{
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
// finally block so its card updates as soon as its own fetch returns.
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
}
private async Task LoadTrackCount()
{
try
{
var result = await CmsTrackService.GetTrackCountAsync();
_trackCount = result.Success ? result.Value : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard track count failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_tracksLoading = false;
StateHasChanged();
}
}
private async Task LoadAlbumCount()
{
try
{
var result = await CmsTrackService.GetReleasesAsync();
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard album summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_albumsLoading = false;
StateHasChanged();
}
}
private async Task LoadGenreCount()
{
try
{
var result = await CmsTrackService.GetGenreSummariesAsync();
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_genresLoading = false;
StateHasChanged();
}
}
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
{
<MudCard Elevation="8" Style="height: 100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
@if (loading)
{
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
}
else
{
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
}
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
</MudStack>
</MudCardContent>
<MudCardActions Class="justify-center pb-4">
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo("/tracks"))">
View
</MudButton>
</MudCardActions>
</MudCard>
};
}
@@ -0,0 +1,115 @@
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@inject IHttpClientFactory HttpClientFactory
<MudPaper Class="pa-6 mb-4" Elevation="2">
<MudGrid>
<MudItem xs="12" sm="6">
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
T="string" Label="Album Name" Required="true" RequiredError="Album Name is required"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="Artist" ValueChanged="@((string v) => ArtistChanged.InvokeAsync(v))"
T="string" Label="Artist" Required="true" RequiredError="Artist is required"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="Genre" ValueChanged="@((string v) => GenreChanged.InvokeAsync(v))"
T="string" Label="Genre" Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField Value="ReleaseDate" ValueChanged="@((string v) => ReleaseDateChanged.InvokeAsync(v))"
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSelect T="ReleaseType" Value="ReleaseType" ValueChanged="@((ReleaseType v) => ReleaseTypeChanged.InvokeAsync(v))"
Label="Release Type" Variant="Variant.Outlined" Disabled="Disabled">
@foreach (var rt in Enum.GetValues<ReleaseType>())
{
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (SelectedImageFile is { } selectedImage)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="Disabled"
OnClick="ClearSelectedFile"
aria-label="Cancel image selection" />
</MudStack>
}
else if (ExistingImagePreviewUrl is { } previewUrl)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudImage Src="@previewUrl"
Alt="Current cover art"
Elevation="1"
Style="max-width: 120px; height: auto; border-radius: 4px;" />
<MudText Typo="Typo.body2" Color="Color.Default">Current cover art.</MudText>
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@Disabled" />
@if (SelectedImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
}
</MudStack>
</MudField>
</MudItem>
</MudGrid>
</MudPaper>
@code {
[Parameter] public string AlbumName { get; set; } = string.Empty;
[Parameter] public EventCallback<string> AlbumNameChanged { get; set; }
[Parameter] public string Artist { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
[Parameter] public string Genre { get; set; } = string.Empty;
[Parameter] public EventCallback<string> GenreChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
// BatchEdit only: when set (and no new file picked), preview the release's current cover.
// The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; }
[Parameter] public bool Disabled { get; set; }
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses.
private string? ExistingImagePreviewUrl
{
get
{
if (string.IsNullOrEmpty(ExistingImagePath)) return null;
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
return baseAddress is null
? null
: new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString();
}
}
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
SelectedImageFileChanged.InvokeAsync(e.File);
private Task ClearSelectedFile() =>
SelectedImageFileChanged.InvokeAsync(null);
}
@@ -0,0 +1,473 @@
@page "/tracks/album/{AlbumName}/edit"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject ILogger<BatchEdit> Logger
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
@if (_loading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_loadError is { } loadError)
{
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
}
else
{
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
@bind-SelectedImageFile="_selectedImageFile"
ExistingImagePath="_existingImagePath"
Disabled="_saving" />
@if (_existingImagePath is not null && _selectedImageFile is null)
{
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
<MudButton Variant="Variant.Text"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Delete"
Disabled="_saving"
OnClick="RemoveCover">
Remove cover
</MudButton>
</MudStack>
}
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_saving"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_saving"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</MudGrid>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
Disabled="_saving">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SaveAsync"
Disabled="@(_saving || _tracks.Count == 0)">
@if (_saving)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
<text>Saving @_processedCount / @_tracks.Count…</text>
}
else
{
<text>Save Changes</text>
}
</MudButton>
</MudStack>
}
</MudContainer>
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 1_073_741_824L;
[Parameter] public string AlbumName { get; set; } = string.Empty;
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _loading = true;
private string? _loadError;
private bool _saving;
private int _processedCount;
private string? _errorMessage;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
private string? _existingImagePath;
private bool _clearExistingImage;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
protected override async Task OnInitializedAsync()
{
// A single page of 100 covers the full release (albums are small — same assumption as
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
var result = await CmsTrackService.GetPagedAsync(
page: 1, pageSize: 100,
sortColumn: "TrackNumber", sortDescending: false,
album: AlbumName);
if (!result.Success || result.Value is null)
{
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
_loading = false;
return;
}
var tracks = result.Value.Items.ToList();
if (tracks.Count == 0)
{
_loadError = $"No tracks found for release '{AlbumName}'.";
_loading = false;
return;
}
var release = tracks[0].Release;
_albumName = AlbumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_existingImagePath = release?.ImagePath;
_tracks = tracks.Select(t => new BatchRowModel
{
Id = t.Id,
EntryKey = t.EntryKey,
OriginalFileName = t.OriginalFileName,
TrackName = t.TrackName,
TrackNumber = t.TrackNumber,
WavFile = null,
Status = BatchRowStatus.Queued
}).ToList();
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
_loading = false;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
foreach (var file in files)
{
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
continue;
}
// New rows carry no Id — they take the upload path on save.
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
}
if (_selectedIndex < 0 && _tracks.Count > 0)
{
_selectedIndex = 0;
}
}
private void MoveUp(int i)
{
if (i == 0) return;
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i - 1;
else if (_selectedIndex == i - 1) _selectedIndex = i;
}
private void MoveDown(int i)
{
if (i == _tracks.Count - 1) return;
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i + 1;
else if (_selectedIndex == i + 1) _selectedIndex = i;
}
private async Task RemoveRow(int index)
{
var row = _tracks[index];
if (row.Id.HasValue)
{
// Existing track — confirm before deleting.
var confirmed = await DialogService.ShowMessageBox(
"Remove track",
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
yesText: "Remove", cancelText: "Cancel");
if (confirmed != true) return;
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
return;
}
}
// New track (not yet uploaded) or confirmed existing delete — remove from list.
_tracks.RemoveAt(index);
if (index < _selectedIndex) _selectedIndex--;
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
}
private void RemoveCover()
{
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
// the existing path here drops the preview in AlbumHeaderFields.
_clearExistingImage = true;
_existingImagePath = null;
}
private async Task SaveAsync()
{
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Album Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_artist))
{
_errorMessage = "Artist is required.";
return;
}
if (_tracks.Count == 0)
{
_errorMessage = "A release must have at least one track.";
return;
}
if (!string.IsNullOrWhiteSpace(_releaseDate)
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
{
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
return;
}
// New rows (no Id) need a WAV; existing rows keep their vault audio.
foreach (var t in _tracks)
{
if (!t.Id.HasValue && t.WavFile is null)
{
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
return;
}
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var createdByUserId))
{
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
? null
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_saving = true;
_processedCount = 0;
try
{
// Upload any newly picked cover art once; abort if it fails so we never point metadata
// at an image that was never stored.
if (_selectedImageFile is { } imgFile)
{
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
if (!imgResult.Success)
{
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Image upload failed: {imgError}";
return;
}
_imagePath = imgResult.Value;
}
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
// sends ""; otherwise null leaves the existing cover untouched.
string? imagePathForUpdate =
_imagePath is { } newPath ? newPath
: _clearExistingImage ? ""
: null;
int succeeded = 0, failed = 0;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
if (row.Status == BatchRowStatus.Done)
{
_processedCount++;
continue;
}
var trackNumber = i + 1; // 1-based ordinal from list position
row.Status = BatchRowStatus.Uploading;
StateHasChanged();
try
{
if (row.Id.HasValue)
{
// Existing track — metadata-only update; audio stays in the vault.
var updateResult = await CmsTrackService.UpdateAsync(
row.Id.Value,
row.TrackName,
_artist,
album,
genre,
releaseDate,
imagePathForUpdate,
_releaseType,
trackNumber);
if (!updateResult.Success)
{
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = error;
failed++;
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
row.TrackName, row.Id.Value, error);
}
else
{
row.Status = BatchRowStatus.Done;
succeeded++;
}
}
else
{
// New track — upload, then link cover art with a follow-up update (same
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
var uploadResult = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
album,
genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
if (!uploadResult.Success || uploadResult.Value is null)
{
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = error;
failed++;
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
row.TrackName, error);
}
else
{
// Link a cover only when one is actively set ("" clear doesn't apply to
// a brand-new track that has no cover yet).
if (imagePathForUpdate is { Length: > 0 } linkPath)
{
var linkResult = await CmsTrackService.UpdateAsync(
uploadResult.Value.Id,
row.TrackName,
_artist,
album,
genre,
releaseDate,
linkPath,
_releaseType,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track persisted; cover can be linked via TrackEdit.
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
row.TrackName, uploadResult.Value.Id);
}
}
row.Status = BatchRowStatus.Done;
succeeded++;
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = "Save failed — please try again.";
failed++;
}
_processedCount++;
StateHasChanged();
}
// Either branch changed catalogue data, so the browse caches are stale regardless of
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
// album and genre lists re-fetch.
VM.Invalidate();
if (failed == 0)
{
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
Navigation.NavigateTo("/tracks/albums");
}
else
{
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
finally
{
_saving = false;
StateHasChanged();
}
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Components.Forms;
namespace DeepDrftManager.Components.Pages.Tracks;
/// <summary>
/// A single track row shared by <c>BatchUpload</c> (all rows are new uploads) and
/// <c>BatchEdit</c> (existing rows carry <see cref="Id"/>; admins may also add new upload rows).
/// </summary>
public class BatchRowModel
{
/// <summary>SQL id of an existing track. <c>null</c> means a new row to upload.</summary>
public long? Id { get; set; }
/// <summary>Vault entry key — existing rows only.</summary>
public string? EntryKey { get; set; }
/// <summary>Original upload filename — existing rows only, read-only display.</summary>
public string? OriginalFileName { get; set; }
/// <summary>Selected WAV — new rows only.</summary>
public IBrowserFile? WavFile { get; set; }
public string TrackName { get; set; } = string.Empty;
public int TrackNumber { get; set; }
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
public string? ErrorMessage { get; set; }
}
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
@@ -0,0 +1,60 @@
@if (SelectedTrack is null)
{
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
}
else
{
<MudStack Spacing="4">
<MudTextField Value="SelectedTrack.TrackName"
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
T="string"
Label="Track Name"
Required="true"
RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="Disabled" />
@if (SelectedTrack.Id.HasValue)
{
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
</MudField>
}
else
{
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
@if (SelectedTrack.WavFile is { } wav)
{
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
}
</MudField>
}
@if (SelectedTrack.Status == BatchRowStatus.Failed)
{
<MudAlert Severity="Severity.Error">@SelectedTrack.ErrorMessage</MudAlert>
}
</MudStack>
}
@code {
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public EventCallback<string> TrackNameChanged { get; set; }
private static string FormatBytes(long bytes)
{
const long KB = 1024;
const long MB = KB * 1024;
const long GB = MB * 1024;
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
return $"{bytes} bytes";
}
}
@@ -0,0 +1,84 @@
@using Microsoft.AspNetCore.Components.Forms
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
@if (AllowNewTracks)
{
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@Disabled" />
}
@if (Tracks.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
}
else
{
<MudList T="BatchRowModel" Class="mt-3">
@for (var i = 0; i < Tracks.Count; i++)
{
var index = i;
var row = Tracks[index];
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
@StatusChip(row)
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
Size="Size.Small"
Disabled="@(index == 0 || Disabled)"
OnClick="@(() => OnMoveUp.InvokeAsync(index))"
aria-label="Move track up" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
Size="Size.Small"
Disabled="@(index == Tracks.Count - 1 || Disabled)"
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
aria-label="Move track down" />
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Disabled="@Disabled"
OnClick="@(() => OnRemove.InvokeAsync(index))"
aria-label="Remove track" />
</MudStack>
</div>
}
</MudList>
}
</MudPaper>
@code {
[Parameter] public List<BatchRowModel> Tracks { get; set; } = new();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowNewTracks { get; set; } = true;
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
[Parameter] public EventCallback<int> OnRemove { get; set; }
private const int MaxFilesPerPick = 50;
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
private Task HandleWavFilesSelected(InputFileChangeEventArgs e) =>
OnWavFilesSelected.InvokeAsync(e.GetMultipleFiles(MaxFilesPerPick));
private string RowStyle(int index)
{
const string baseStyle = "cursor: pointer; border-radius: 4px;";
return index == SelectedIndex
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
: baseStyle;
}
private RenderFragment StatusChip(BatchRowModel row) => row.Status switch
{
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.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>
};
}
@@ -0,0 +1,309 @@
@page "/tracks/upload"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
<AlbumHeaderFields @bind-AlbumName="_albumName"
@bind-Artist="_artist"
@bind-Genre="_genre"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
@bind-SelectedImageFile="_selectedImageFile"
Disabled="_uploading" />
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_uploading"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</MudGrid>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
Disabled="_uploading">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SubmitAsync"
Disabled="@(_uploading || _tracks.Count == 0)">
@if (_uploading)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
<text>Uploading @_uploadedCount / @_tracks.Count…</text>
}
else
{
<text>Upload Release</text>
}
</MudButton>
</MudStack>
</MudContainer>
@code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 1_073_741_824L;
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _uploading;
private int _uploadedCount;
private string? _errorMessage;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
foreach (var file in files)
{
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
continue;
}
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
}
if (_selectedIndex < 0 && _tracks.Count > 0)
{
_selectedIndex = 0;
}
}
private void MoveUp(int i)
{
if (i == 0) return;
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i - 1;
else if (_selectedIndex == i - 1) _selectedIndex = i;
}
private void MoveDown(int i)
{
if (i == _tracks.Count - 1) return;
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
if (_selectedIndex == i) _selectedIndex = i + 1;
else if (_selectedIndex == i + 1) _selectedIndex = i;
}
private void RemoveRow(int i)
{
_tracks.RemoveAt(i);
if (i < _selectedIndex) _selectedIndex--;
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
}
private async Task SubmitAsync()
{
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Album Name is required.";
return;
}
if (string.IsNullOrWhiteSpace(_artist))
{
_errorMessage = "Artist is required.";
return;
}
if (_tracks.Count == 0)
{
_errorMessage = "Add at least one track.";
return;
}
if (!string.IsNullOrWhiteSpace(_releaseDate)
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
{
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
return;
}
foreach (var t in _tracks)
{
if (t.WavFile is null)
{
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
return;
}
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(userIdValue, out var 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);
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_uploading = true;
_uploadedCount = 0;
try
{
// Upload any selected cover art once; abort the submit if it fails so we never
// create tracks expecting an image that was never stored in the vault.
if (_selectedImageFile is { } imgFile)
{
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
if (!imgResult.Success)
{
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Image upload failed: {imgError}";
return;
}
_imagePath = imgResult.Value;
}
int succeeded = 0, failed = 0;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
var trackNumber = i + 1; // 1-based ordinal from list position
row.Status = BatchRowStatus.Uploading;
StateHasChanged();
try
{
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in StreamContent so the whole file is never materialised in
// memory before DeepDrftAPI receives it.
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
var result = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = error;
failed++;
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
}
else
{
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
if (_imagePath is { } imgPath)
{
var linkResult = await CmsTrackService.UpdateAsync(
result.Value.Id,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
row.TrackName, result.Value.Id);
}
}
row.Status = BatchRowStatus.Done;
succeeded++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = "Upload failed — please try again.";
failed++;
}
_uploadedCount++;
StateHasChanged();
}
if (failed == 0)
{
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
VM.Invalidate();
Navigation.NavigateTo("/tracks");
}
else
{
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
finally
{
_uploading = false;
StateHasChanged();
}
}
}
@@ -0,0 +1,287 @@
@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<CmsAlbumBrowser> Logger
@if (IsLoading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (_rows.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
}
else
{
<MudTable T="AlbumRow"
Items="_rows"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true">
<HeaderContent>
<MudTh Style="width: 1%;"></MudTh>
<MudTh Style="width: 1%;">Art</MudTh>
<MudTh>Album</MudTh>
<MudTh>Artist</MudTh>
<MudTh>Genre</MudTh>
<MudTh>Release Date</MudTh>
<MudTh>Type</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
OnClick="@(() => ToggleExpand(context))" />
</MudTd>
<MudTd DataLabel="Art">
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
{
<div class="cms-album-thumb"
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
}
else
{
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Album">@context.Release.Title</MudTd>
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Type">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@context.Release.ReleaseType</MudChip>
</MudTd>
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Batch Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
</MudTooltip>
<MudTooltip Text="Delete release">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
Disabled="@context.IsDeleting"
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
</MudTooltip>
</MudTd>
</RowTemplate>
<ChildRowContent>
@if (context.IsExpanded)
{
<MudTr>
<MudTd colspan="9" Style="padding: 0;">
@if (context.IsLoading)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
<MudText Typo="Typo.body2">Loading tracks…</MudText>
</MudStack>
}
else if (context.Tracks is { Count: 0 })
{
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
}
else if (context.Tracks is not null)
{
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
Elevation="0" Style="background: transparent;">
<HeaderContent>
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
<MudTh>Track Name</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
</RowTemplate>
</MudTable>
}
</MudTd>
</MudTr>
}
</ChildRowContent>
</MudTable>
}
@code {
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnReleasesChanged { get; set; }
private List<AlbumRow> _rows = new();
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
// back. We only re-project when the parent hands us a genuinely new list.
private IReadOnlyList<ReleaseDto>? _projectedReleases;
// The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated,
// so the browser hits it directly. Base address comes from the same named client the CMS uses.
private Uri? _contentApiBase;
protected override void OnInitialized() =>
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
// same cached VM.Albums instance.
protected override void OnParametersSet()
{
if (!ReferenceEquals(_projectedReleases, Releases))
{
_projectedReleases = Releases;
_rows = Releases.Select(r => new AlbumRow { Release = r }).ToList();
}
}
private string? ThumbUrl(string imagePath) =>
_contentApiBase is null
? null
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
private async Task ToggleExpand(AlbumRow row)
{
row.IsExpanded = !row.IsExpanded;
if (row.IsExpanded && row.Tracks is null && !row.IsLoading)
{
row.IsLoading = true;
StateHasChanged();
row.Tracks = await LoadTracksAsync(row.Release.Title);
row.IsLoading = false;
}
}
// Albums are small releases; a single page of 100 always covers the full track list (see brief).
private async Task<List<TrackDto>> LoadTracksAsync(string albumTitle)
{
var result = await CmsTrackService.GetPagedAsync(
page: 1, pageSize: 100,
sortColumn: "TrackNumber", sortDescending: false,
album: albumTitle);
if (result.Success && result.Value is not null)
{
return result.Value.Items.ToList();
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error);
return new List<TrackDto>();
}
private async Task ConfirmAndDeleteAlbum(AlbumRow row)
{
// Need track IDs to delete; load them if the row was never expanded.
row.Tracks ??= await LoadTracksAsync(row.Release.Title);
var tracks = row.Tracks;
var count = tracks.Count;
if (count == 0)
{
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
// cannot be cleared by deleting tracks. Delete the release record directly instead.
await ConfirmAndDeleteEmptyReleaseAsync(row);
return;
}
var confirmed = await DialogService.ShowMessageBox(
title: "Delete release",
markupMessage: new MarkupString(
$"Delete all <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? This removes metadata and audio for every track."),
yesText: "Delete all",
cancelText: "Cancel");
if (confirmed != true) return;
row.IsDeleting = true;
StateHasChanged();
var failures = 0;
foreach (var track in tracks)
{
try
{
var del = await CmsTrackService.DeleteTrackAsync(track.Id);
if (!del.Success) failures++;
}
catch (Exception ex)
{
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
failures++;
}
}
row.IsDeleting = false;
if (failures == 0)
{
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
_rows.Remove(row);
await OnReleasesChanged.InvokeAsync();
}
else
{
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
await OnReleasesChanged.InvokeAsync();
}
StateHasChanged();
}
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
// parent so the cached VM.Albums stays in sync with what is shown.
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete release",
markupMessage: new MarkupString(
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
row.IsDeleting = true;
StateHasChanged();
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
row.IsDeleting = false;
if (result.Success)
{
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
_rows.Remove(row);
await OnReleasesChanged.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
StateHasChanged();
}
private sealed class AlbumRow
{
public required ReleaseDto Release { get; init; }
public List<TrackDto>? Tracks { get; set; } // null = not yet loaded
public bool IsExpanded { get; set; }
public bool IsLoading { get; set; }
public bool IsDeleting { get; set; }
// Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load.
public int TrackCount => Release.TrackCount;
}
}
@@ -0,0 +1,12 @@
.cms-album-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.cms-album-thumb--fallback {
background-color: var(--mud-palette-action-default-hover);
}
@@ -0,0 +1,52 @@
@using DeepDrftModels.DTOs
@if (IsLoading)
{
<MudProgressCircular Indeterminate="true" Class="mt-4" />
}
else if (Genres.Count == 0)
{
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
}
else
{
<MudGrid Spacing="3" Class="mt-2">
@foreach (var genre in Genres)
{
var isExpanded = ExpandedGenre == genre.Genre;
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="@(isExpanded ? 4 : 1)"
Style="cursor: pointer;"
@onclick="@(() => ToggleGenre(genre.Genre))">
<div class="@SwatchClass(isExpanded)"></div>
<MudCardContent>
<MudText Typo="Typo.h6">@genre.Genre</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
</MudCardContent>
</MudCard>
</MudItem>
}
</MudGrid>
@if (ExpandedGenre is not null)
{
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
}
}
@code {
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string? ExpandedGenre { get; set; }
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
private async Task ToggleGenre(string genre) =>
await OnExpandedGenreChanged.InvokeAsync(genre);
private static string SwatchClass(bool isExpanded) =>
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
}
@@ -0,0 +1,10 @@
.cms-genre-swatch {
width: 100%;
height: 80px;
background-color: var(--mud-palette-action-default-hover);
transition: background-color 0.2s ease;
}
.cms-genre-swatch--active {
background-color: var(--mud-palette-primary-hover);
}
@@ -0,0 +1,265 @@
@using System.Net
@using DeepDrftManager.Services
@using DeepDrftModels.DTOs
@inject ICmsTrackService CmsTrackService
@inject IHttpClientFactory HttpClientFactory
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<CmsTrackGrid> Logger
@inject NavigationManager NavigationManager
@if (ShowAddButton)
{
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/upload">
Add Track
</MudButton>
</MudStack>
}
<MudTable T="TrackDto"
@ref="_table"
ServerData="LoadServerData"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true"
RowsPerPage="@PageSize"
AllowUnsorted="false">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading tracks…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
<MudTd DataLabel="Art">
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
{
<div class="cms-track-thumb"
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
}
else
{
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
}
</MudTd>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
<MudTd DataLabel="Waveform">
@if (HasProfile(context.EntryKey))
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
}
else
{
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
}
</MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmAndDelete(context))" />
</MudTooltip>
<MudTooltip>
<TooltipContent>
<div class="cms-track-info">
<div>Entry: @context.EntryKey</div>
<div>File: @(context.OriginalFileName ?? "—")</div>
</div>
</TooltipContent>
<ChildContent>
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
</ChildContent>
</MudTooltip>
@if (!HasProfile(context.EntryKey))
{
<MudTooltip Text="Generate Waveform">
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
Size="Size.Small"
Color="Color.Secondary"
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
OnClick="@(() => GenerateOneAsync(context))" />
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
</PagerContent>
</MudTable>
@code {
[Parameter] public string? AlbumFilter { get; set; }
[Parameter] public string? GenreFilter { get; set; }
[Parameter] public bool ShowAddButton { get; set; } = true;
[Parameter] public int PageSize { get; set; } = 20;
[Parameter] public EventCallback OnTracksChanged { get; set; }
[Parameter] public EventCallback OnStatusLoaded { get; set; }
private MudTable<TrackDto>? _table;
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
private Dictionary<string, bool> _waveformStatus = new();
private readonly HashSet<string> _generating = new();
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
private bool _bulkRunning;
// The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so
// the browser hits it directly. Base address comes from the same named client the CMS uses.
private Uri? _contentApiBase;
protected override async Task OnInitializedAsync()
{
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
await RefreshWaveformStatusAsync();
}
private bool HasProfile(string entryKey) =>
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
private string? ThumbUrl(string imagePath) =>
_contentApiBase is null
? null
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
/// <summary>
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
/// the per-row icons reflect the new state.
/// </summary>
public async Task RefreshWaveformStatusAsync()
{
var result = await CmsTrackService.GetWaveformStatusAsync();
_waveformStatus = result.Success && result.Value is not null
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
: new Dictionary<string, bool>();
StateHasChanged();
await OnStatusLoaded.InvokeAsync();
}
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
public void SetBulkRunning(bool running)
{
_bulkRunning = running;
StateHasChanged();
}
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
{
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
var sortDescending = state.SortDirection == SortDirection.Descending;
var result = await CmsTrackService.GetPagedAsync(
pageNumber, state.PageSize, sortColumn, sortDescending,
AlbumFilter, GenreFilter, cancellationToken);
if (!result.Success || result.Value is null)
{
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
}
var page = result.Value;
return new TableData<TrackDto>
{
Items = page.Items,
TotalItems = page.TotalCount
};
}
private async Task ConfirmAndDelete(TrackDto track)
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete track",
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
yesText: "Delete",
cancelText: "Cancel");
if (confirmed != true) return;
try
{
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
if (result.Success)
{
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
if (_table is not null) await _table.ReloadServerData();
await OnTracksChanged.InvokeAsync();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
Snackbar.Add("Delete failed — please try again.", Severity.Error);
}
}
private async Task GenerateOneAsync(TrackDto track)
{
_generating.Add(track.EntryKey);
StateHasChanged();
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
if (result.Success)
{
_waveformStatus[track.EntryKey] = true;
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
}
finally
{
_generating.Remove(track.EntryKey);
StateHasChanged();
}
}
}
@@ -0,0 +1,17 @@
.cms-track-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
.cms-track-thumb--fallback {
background-color: var(--mud-palette-action-default-hover);
}
.cms-track-info {
font-family: monospace;
text-align: left;
}
@@ -1,7 +1,11 @@
@page "/tracks/{Id:long}"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject CmsTrackBrowserViewModel VM
@inject IHttpClientFactory HttpClientFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@@ -60,6 +64,62 @@
Label="Genre"
Variant="Variant.Outlined" />
<MudSelect @bind-Value="_form.ReleaseType"
Label="Release Type"
Variant="Variant.Outlined">
@foreach (var releaseType in Enum.GetValues<ReleaseType>())
{
<MudSelectItem Value="releaseType">@releaseType</MudSelectItem>
}
</MudSelect>
<MudNumericField @bind-Value="_form.TrackNumber"
Label="Track Number"
Min="1"
Variant="Variant.Outlined" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (ImagePreviewUrl is { } previewUrl)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudImage Src="@previewUrl"
Alt="Cover art preview"
Elevation="1"
Style="max-width: 120px; height: auto; border-radius: 4px;" />
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Clear cover art" />
</MudStack>
}
else if (_selectedImageFile is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_busy"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
@if (_selectedImageFile is { } selected)
{
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
}
</MudStack>
</MudField>
<MudDatePicker @bind-Date="_form.ReleaseDate"
Label="Release Date"
DateFormat="yyyy-MM-dd"
@@ -94,11 +154,25 @@
private TrackEditForm _form = new();
private bool _loading = true;
private bool _busy;
private IBrowserFile? _selectedImageFile;
private bool CanSave =>
!string.IsNullOrWhiteSpace(_form.TrackName)
&& !string.IsNullOrWhiteSpace(_form.Artist);
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes.
private string? ImagePreviewUrl
{
get
{
if (string.IsNullOrEmpty(_form.ImagePath)) return null;
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
if (baseAddress is null) return null;
return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString();
}
}
protected override async Task OnInitializedAsync()
{
await LoadAsync();
@@ -123,16 +197,40 @@
_busy = true;
try
{
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
// API loads the authoritative row and applies these fields.
// Upload any newly picked cover art first; abort the save if it fails so we never
// persist metadata pointing at an image that was never stored.
if (_selectedImageFile is { } file)
{
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
var uploadResult = await CmsTrackService.UploadImageAsync(
imageStream, file.Name, file.ContentType);
if (!uploadResult.Success)
{
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
return;
}
_form.ImagePath = uploadResult.Value;
_selectedImageFile = null;
}
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
// loads the authoritative row and applies these fields. imagePath is tri-state: an
// explicit empty string clears the link, a value sets it.
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
var updated = await CmsTrackService.UpdateAsync(
Id, _form.TrackName, _form.Artist,
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
releaseDate);
releaseDate,
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
_form.ReleaseType,
_form.TrackNumber);
if (updated.Success)
{
// Album/genre browse lists derive from this track's metadata; drop their cache so
// the /tracks browser re-fetches fresh data on next mode switch.
VM.Invalidate();
Snackbar.Add("Track updated.", Severity.Success);
await LoadAsync();
}
@@ -153,13 +251,24 @@
}
}
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
}
private void ClearImage()
{
_form.ImagePath = null;
_selectedImageFile = null;
}
private async Task ConfirmDelete()
{
if (_track is null) return;
var confirmed = await DialogService.ShowMessageBox(
"Delete track",
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
yesText: "Delete",
cancelText: "Cancel");
@@ -171,6 +280,9 @@
var result = await CmsTrackService.DeleteTrackAsync(Id);
if (result.Success)
{
// Deleting a track can empty or alter a release; drop the browse cache so the
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
VM.Invalidate();
Snackbar.Add("Track deleted.", Severity.Success);
Nav.NavigateTo("/tracks");
}
@@ -197,17 +309,23 @@
public string Artist { get; set; } = string.Empty;
public string? Album { get; set; }
public string? Genre { get; set; }
public string? ImagePath { get; set; }
public DateTime? ReleaseDate { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
public int TrackNumber { get; set; } = 1;
public static TrackEditForm From(TrackDto track) => new()
{
TrackName = track.TrackName,
Artist = track.Artist,
Album = track.Album,
Genre = track.Genre,
ReleaseDate = track.ReleaseDate is { } d
Artist = track.Release?.Artist ?? string.Empty,
Album = track.Release?.Title,
Genre = track.Release?.Genre,
ImagePath = track.Release?.ImagePath,
ReleaseDate = track.Release?.ReleaseDate is { } d
? d.ToDateTime(TimeOnly.MinValue)
: null
: null,
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
TrackNumber = track.TrackNumber
};
}
}
@@ -1,132 +1,179 @@
@page "/tracks"
@using System.Net
@page "/tracks/albums"
@page "/tracks/genres"
@using DeepDrftManager.Services
@attribute [Authorize]
@inject CmsTrackBrowserViewModel VM
@inject ICmsTrackService CmsTrackService
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject ILogger<TrackList> Logger
@inject NavigationManager NavigationManager
@attribute [Authorize]
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
<MudText Typo="Typo.h3">Tracks</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
Href="/tracks/new">
Add Track
</MudButton>
@if (VM.Mode == BrowseMode.Tracks)
{
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.AutoFixHigh"
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
OnClick="GenerateAllMissingAsync">
@if (_bulkRunning)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Generating @_bulkDone / @_bulkTotal…</span>
}
else
{
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
}
</MudButton>
}
</MudStack>
<MudTable T="TrackDto"
@ref="_table"
ServerData="LoadServerData"
Hover="true"
Striped="true"
Dense="true"
Bordered="false"
FixedHeader="true"
RowsPerPage="20"
AllowUnsorted="false">
<NoRecordsContent>
<MudText Typo="Typo.body1">No tracks found.</MudText>
</NoRecordsContent>
<LoadingContent>
<MudText Typo="Typo.body1">Loading tracks…</MudText>
</LoadingContent>
<HeaderContent>
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
<MudTh>Entry Key</MudTh>
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
<MudTd DataLabel="Artist">@context.Artist</MudTd>
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
<MudTd DataLabel="Actions">
<MudTooltip Text="Edit">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
Href="@($"/tracks/{context.Id}")" />
</MudTooltip>
<MudTooltip Text="Delete">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => ConfirmAndDelete(context))" />
</MudTooltip>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
</PagerContent>
</MudTable>
<MudToggleGroup T="BrowseMode"
Value="VM.Mode"
ValueChanged="OnModeChanged"
SelectionMode="SelectionMode.SingleSelection"
Color="Color.Primary"
Size="Size.Small"
Class="mb-4">
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
</MudToggleGroup>
@if (VM.Mode == BrowseMode.Tracks)
{
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
}
else if (VM.Mode == BrowseMode.Albums)
{
<CmsAlbumBrowser Releases="VM.Albums"
IsLoading="VM.AlbumsLoading"
OnReleasesChanged="OnAlbumsChanged" />
}
else
{
<CmsGenreBrowser Genres="VM.Genres"
IsLoading="VM.GenresLoading"
ExpandedGenre="@VM.ExpandedGenre"
OnExpandedGenreChanged="OnExpandedGenreChanged" />
}
</MudContainer>
@code {
private MudTable<TrackDto>? _table;
private CmsTrackGrid? _grid;
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
// The album browser owns its own row state and removes a deleted release locally. Invalidate the
// VM cache so genres and album counts reflect the deletion on next mode switch.
private void OnAlbumsChanged()
{
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
var sortDescending = state.SortDirection == SortDirection.Descending;
var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken);
if (!result.Success || result.Value is null)
{
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
}
var page = result.Value;
return new TableData<TrackDto>
{
Items = page.Items,
TotalItems = page.TotalCount
};
VM.Invalidate();
StateHasChanged();
}
private async Task ConfirmAndDelete(TrackDto track)
// Local state for the parent-owned "Generate All Missing" bulk run.
private bool _bulkRunning;
private int _bulkTotal;
private int _bulkDone;
protected override async Task OnInitializedAsync()
{
var confirmed = await DialogService.ShowMessageBox(
title: "Delete track",
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
yesText: "Delete",
cancelText: "Cancel");
var uri = NavigationManager.Uri;
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
? BrowseMode.Albums
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
? BrowseMode.Genres
: BrowseMode.Tracks;
await VM.SwitchModeAsync(initial);
}
if (confirmed != true) return;
try
private async Task OnModeChanged(BrowseMode mode)
{
await VM.SwitchModeAsync(mode);
var path = mode switch
{
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
if (result.Success)
{
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
if (_table is not null) await _table.ReloadServerData();
}
else
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Delete failed: {error}", Severity.Error);
}
BrowseMode.Albums => "/tracks/albums",
BrowseMode.Genres => "/tracks/genres",
_ => "/tracks"
};
NavigationManager.NavigateTo(path, replace: true);
StateHasChanged();
}
private void OnExpandedGenreChanged(string? genre)
{
VM.SetExpandedGenre(genre);
StateHasChanged();
}
/// <summary>
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
/// status map so the per-row icons reflect the new state.
/// </summary>
private async Task GenerateAllMissingAsync()
{
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
if (!statusResult.Success || statusResult.Value is null)
{
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
return;
}
catch (Exception ex)
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
if (missing.Count == 0)
{
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
Snackbar.Add("Delete failed — please try again.", Severity.Error);
return;
}
_bulkRunning = true;
_bulkTotal = missing.Count;
_bulkDone = 0;
_grid?.SetBulkRunning(true);
var failures = 0;
foreach (var status in missing)
{
try
{
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
if (!result.Success)
{
failures++;
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
failures++;
}
_bulkDone++;
StateHasChanged();
}
_bulkRunning = false;
_grid?.SetBulkRunning(false);
if (_grid is not null)
{
await _grid.RefreshWaveformStatusAsync();
}
var succeeded = missing.Count - failures;
if (failures == 0)
{
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
}
else
{
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
}
}
}
@@ -1,6 +1,7 @@
@page "/tracks/new"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@@ -8,6 +9,7 @@
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<TrackNew> Logger
@inject CmsTrackBrowserViewModel VM
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
@@ -29,6 +31,34 @@
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<MudStack Spacing="3">
@if (_selectedImageFile is { } selectedImage)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Clear"
Color="Color.Error"
Size="Size.Small"
Disabled="_isUploading"
OnClick="ClearImage"
aria-label="Cancel image selection" />
</MudStack>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
}
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
@if (_selectedImageFile is not null)
{
<MudText Typo="Typo.caption">Will upload on save.</MudText>
}
</MudStack>
</MudField>
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
@if (!string.IsNullOrEmpty(_errorMessage))
@@ -67,6 +97,8 @@
private const long MaxUploadBytes = 1_073_741_824L;
private IBrowserFile? _selectedFile;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
private string _trackName = string.Empty;
private string _artist = string.Empty;
private string _album = string.Empty;
@@ -81,6 +113,18 @@
_errorMessage = null;
}
private void HandleImageFileSelected(InputFileChangeEventArgs e)
{
_selectedImageFile = e.File;
_imagePath = null;
}
private void ClearImage()
{
_selectedImageFile = null;
_imagePath = null;
}
private async Task SubmitAsync()
{
_errorMessage = null;
@@ -130,6 +174,21 @@
_isUploading = true;
try
{
// Upload any selected cover art first; abort the submit if it fails so we never
// create a track expecting an image that was never stored in the vault.
if (_selectedImageFile is { } imgFile)
{
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
if (!imgResult.Success)
{
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Image upload failed: {imgError}";
return;
}
_imagePath = imgResult.Value;
}
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in StreamContent so the whole file is never materialised in
// memory before DeepDrftAPI receives it.
@@ -144,11 +203,35 @@
string.IsNullOrWhiteSpace(_album) ? null : _album,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
createdByUserId);
_selectedFile.Name,
createdByUserId,
releaseType: ReleaseType.Single,
trackNumber: 1);
if (result.Success)
{
// The upload endpoint does not accept an imagePath, so link the cover art with a
// follow-up metadata update — same two-step pattern TrackEdit uses.
if (_imagePath is { } imgPath && result.Value is { } created)
{
var linkResult = await CmsTrackService.UpdateAsync(
created.Id,
_trackName,
_artist,
string.IsNullOrWhiteSpace(_album) ? null : _album,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath);
if (!linkResult.Success)
{
// Track was created; image is in the vault but unlinked. Non-blocking —
// the user can attach it via Edit.
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
}
}
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
VM.Invalidate();
Navigation.NavigateTo("/tracks");
return;
}
-1
View File
@@ -17,4 +17,3 @@
</ItemGroup>
</Project>
+3
View File
@@ -23,6 +23,9 @@ builder.Services.AddMudServices();
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
@@ -0,0 +1,84 @@
using DeepDrftModels.DTOs;
namespace DeepDrftManager.Services;
/// <summary>The three browse dimensions for the /tracks page.</summary>
public enum BrowseMode
{
Tracks,
Albums,
Genres,
}
/// <summary>
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
/// state here.
/// </summary>
public class CmsTrackBrowserViewModel
{
private readonly ICmsTrackService _trackService;
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
{
_trackService = trackService;
}
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
// Album mode.
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
public bool AlbumsLoading { get; private set; }
// Genre mode.
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
public bool GenresLoading { get; private set; }
public string? ExpandedGenre { get; private set; }
/// <summary>
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there.
/// </summary>
public async Task SwitchModeAsync(BrowseMode mode)
{
Mode = mode;
ExpandedGenre = null; // collapse on mode switch
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading)
{
AlbumsLoading = true;
var result = await _trackService.GetReleasesAsync();
Albums = result.Success && result.Value is not null
? result.Value
: Array.Empty<ReleaseDto>();
AlbumsLoading = false;
}
else if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
{
GenresLoading = true;
var result = await _trackService.GetGenreSummariesAsync();
Genres = result.Success && result.Value is not null
? result.Value
: Array.Empty<GenreSummaryDto>();
GenresLoading = false;
}
}
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
public void SetExpandedGenre(string? genre)
{
ExpandedGenre = ExpandedGenre == genre ? null : genre;
}
/// <summary>
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete)
/// since both datasets are derived from the catalogue and go stale on any such change.
/// </summary>
public void Invalidate()
{
Albums = Array.Empty<ReleaseDto>();
Genres = Array.Empty<GenreSummaryDto>();
}
}
+313 -1
View File
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
@@ -39,7 +40,10 @@ public class CmsTrackService : ICmsTrackService
string? album,
string? genre,
string? releaseDate,
string? originalFileName,
long createdByUserId,
ReleaseType releaseType,
int trackNumber,
CancellationToken ct = default)
{
// Rebuild the multipart container so the boundary is owned by HttpClient and the
@@ -48,13 +52,17 @@ public class CmsTrackService : ICmsTrackService
var wavContent = new StreamContent(wavStream);
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
multipart.Add(wavContent, "wav", fileName);
multipart.Add(wavContent, "audioFile", fileName);
multipart.Add(new StringContent(trackName), "trackName");
multipart.Add(new StringContent(artist), "artist");
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
@@ -144,8 +152,42 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.DeleteAsync($"api/track/release/{releaseId}", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for delete of release {ReleaseId}", releaseId);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Release not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API delete failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to delete release.");
}
}
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
string? album = null, string? genre = null,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -154,6 +196,14 @@ public class CmsTrackService : ICmsTrackService
{
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
}
if (!string.IsNullOrWhiteSpace(album))
{
query += $"&album={Uri.EscapeDataString(album)}";
}
if (!string.IsNullOrWhiteSpace(genre))
{
query += $"&genre={Uri.EscapeDataString(genre)}";
}
HttpResponseMessage response;
try
@@ -238,9 +288,89 @@ public class CmsTrackService : ICmsTrackService
}
}
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
};
public async Task<ResultContainer<string>> UploadImageAsync(
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(contentType) || !KnownImageMimeTypes.Contains(contentType))
{
_logger.LogWarning("UploadImageAsync rejected: unsupported or missing content type '{ContentType}'", contentType);
return ResultContainer<string>.CreateFailResult($"Unsupported image type: {contentType}. Accepted: JPEG, PNG, GIF, WebP, SVG, BMP.");
}
using var multipart = new MultipartFormDataContent();
var imageContent = new StreamContent(imageStream);
imageContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
multipart.Add(imageContent, "image", fileName);
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart };
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName);
return ResultContainer<string>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
var statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
_logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body);
return ResultContainer<string>.CreateFailResult("Image upload failed on the content server.");
}
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
_logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body);
return ResultContainer<string>.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body);
}
ImageUploadResponse? payload;
try
{
payload = await response.Content.ReadFromJsonAsync<ImageUploadResponse>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize image upload response from Content API");
return ResultContainer<string>.CreateFailResult("Content API returned an unexpected response.");
}
if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey))
{
_logger.LogError("Content API returned an empty entry key for image upload");
return ResultContainer<string>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<string>.CreatePassResult(payload.EntryKey);
}
}
private sealed record ImageUploadResponse(string EntryKey);
public async Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
int? trackNumber = null,
CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -251,6 +381,9 @@ public class CmsTrackService : ICmsTrackService
album,
genre,
releaseDate,
imagePath,
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
trackNumber,
};
HttpResponseMessage response;
@@ -281,4 +414,183 @@ public class CmsTrackService : ICmsTrackService
return Result.CreateFailResult("Failed to update track.");
}
}
public async Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/waveform-status", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform status");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode);
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Failed to load waveform status.");
}
WaveformStatusDto[]? status;
try
{
status = await response.Content.ReadFromJsonAsync<WaveformStatusDto[]>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize waveform status from Content API response");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
}
if (status is null)
{
_logger.LogError("Content API returned a null waveform status list");
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<WaveformStatusDto[]>.CreatePassResult(status);
}
}
public async Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey);
return Result.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track audio not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
return Result.CreateFailResult("Failed to generate waveform profile.");
}
}
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/albums", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for releases");
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode);
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Failed to load albums.");
}
List<ReleaseDto>? releases;
try
{
releases = await response.Content.ReadFromJsonAsync<List<ReleaseDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize releases from Content API response");
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
}
if (releases is null)
{
_logger.LogError("Content API returned a null releases list");
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<List<ReleaseDto>>.CreatePassResult(releases);
}
}
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/track/genres", ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for genre summaries");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
}
List<GenreSummaryDto>? genres;
try
{
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
}
if (genres is null)
{
_logger.LogError("Content API returned a null genre summaries list");
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
}
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
}
}
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
{
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
// dedicated count endpoint is needed.
var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct);
if (!paged.Success || paged.Value is null)
{
var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count.";
return ResultContainer<int>.CreateFailResult(error);
}
return ResultContainer<int>.CreatePassResult(paged.Value.TotalCount);
}
}
+54 -2
View File
@@ -1,4 +1,5 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
@@ -15,6 +16,8 @@ public interface ICmsTrackService
/// Proxy a WAV upload to DeepDrftAPI. The Content API owns the dual-database write and
/// returns the persisted track carrying the SQL-assigned <c>Id</c>. A vault-without-SQL
/// orphan is handled and logged server-side; here it surfaces as a failed result.
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
/// stored as metadata; it is not user-editable afterwards.
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -25,7 +28,10 @@ public interface ICmsTrackService
string? album,
string? genre,
string? releaseDate,
string? originalFileName,
long createdByUserId,
ReleaseType releaseType,
int trackNumber,
CancellationToken ct = default);
/// <summary>
@@ -35,10 +41,19 @@ public interface ICmsTrackService
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
/// <summary>
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>.
/// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release
/// has no live tracks and needs to be removed from the albums browser.
/// </summary>
Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default);
/// <summary>
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>. Optional
/// <paramref name="album"/> and <paramref name="genre"/> filters narrow the result to a single
/// release title or genre; null leaves the dimension unfiltered.
/// </summary>
Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
string? album = null, string? genre = null,
CancellationToken ct = default);
/// <summary>
@@ -47,12 +62,49 @@ public interface ICmsTrackService
/// </summary>
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default);
/// <summary>
/// Upload a cover-art image to the images vault via <c>POST api/image/upload</c>.
/// Returns the generated entry key on success. Maps a 400 to a validation failure message.
/// </summary>
Task<ResultContainer<string>> UploadImageAsync(
Stream imageStream,
string fileName,
string contentType,
CancellationToken ct = default);
/// <summary>
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
/// not part of the update.
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
/// </summary>
Task<Result> UpdateAsync(
long id, string trackName, string artist,
string? album, string? genre, DateOnly? releaseDate,
string? imagePath = null,
ReleaseType? releaseType = null,
int? trackNumber = null,
CancellationToken ct = default);
/// <summary>
/// Fetch per-track waveform profile status from <c>GET api/track/waveform-status</c> for the
/// CMS PreProcessing panel. Unpaged — the admin catalogue is small.
/// </summary>
Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default);
/// <summary>
/// Trigger waveform profile generation for a single track via
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
/// </summary>
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
/// <summary>
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
/// </summary>
Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default);
}
+2
View File
@@ -0,0 +1,2 @@
/* Suppress the browser focus ring that FocusOnNavigate triggers on h1 after navigation. */
h1:focus-visible { outline: none; }
+15
View File
@@ -0,0 +1,15 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// One distinct album with its track count and a representative cover image key. Backs the
/// /albums browse grid.
/// </summary>
[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")]
public class AlbumSummaryDto
{
public required string Album { get; set; }
public int TrackCount { get; set; }
/// <summary>ImagePath of the first track in the album that has one; null when none do.</summary>
public string? CoverImageKey { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace DeepDrftModels.DTOs;
/// <summary>One distinct genre with its track count. Backs the /genres browse list.</summary>
public class GenreSummaryDto
{
public required string Genre { get; set; }
public int TrackCount { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
using DeepDrftModels.Enums;
using Models.Models;
namespace DeepDrftModels.DTOs;
// Mirror of ReleaseEntity (Phase 8 §8.0). Inherits Id, CreatedAt, UpdatedAt from BaseModel
// (Cerebellum.BlazorBlocks.Models). No `required` members — BlazorBlocks's Manager<> generic
// constraint requires `new()`, which does not compose with required members (see TrackDto header).
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
public class ReleaseDto : BaseModel
{
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
public long? CreatedByUserId { get; set; }
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
// service projects it from the joined Tracks collection so the /albums browse grid and the CMS
// dashboard can show a per-album track count. Defaults to 0 when not populated.
public int TrackCount { get; set; }
}
+9 -9
View File
@@ -5,17 +5,17 @@ namespace DeepDrftModels.DTOs;
// Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models).
// BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which
// disqualifies `required` properties (the `new()` constraint and required members do not
// compose). EntryKey/TrackName/Artist therefore drop `required` here — the TrackEntity
// side remains required, and TrackConverter assigns every field on the round-trip so an
// empty default is never observable in production code paths.
// compose). EntryKey/TrackName therefore drop `required` here — the TrackEntity side remains
// required, and TrackConverter assigns every field on the round-trip so an empty default is
// never observable in production code paths.
//
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields are read via Release?.X.
public class TrackDto : BaseModel
{
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string? Album { get; set; }
public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public long? CreatedByUserId { get; set; }
public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1;
public long? ReleaseId { get; set; }
public ReleaseDto? Release { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Cross-project track filter contract. Threaded alongside (never inside) the external
/// <c>PagingParameters&lt;T&gt;</c>, which cannot carry a where-clause. An instance with all
/// properties null is equivalent to no filter — see <c>TrackFilter.IsEmpty</c>.
/// </summary>
public class TrackFilter
{
/// <summary>Free-text, case-insensitive LIKE across TrackName, Artist, and Album.</summary>
public string? SearchText { get; set; }
/// <summary>Exact album match.</summary>
public string? Album { get; set; }
/// <summary>Exact genre match.</summary>
public string? Genre { get; set; }
/// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying.
/// </summary>
public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Album)
&& string.IsNullOrWhiteSpace(Genre);
}
+14
View File
@@ -0,0 +1,14 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire contract for a stored waveform loudness profile. <see cref="Data"/> is the base64
/// encoding of a <c>byte[BucketCount]</c>, each byte a peak-normalized loudness value in
/// [0, 255] (the quantized form of a [0, 1] float). The frontend renders these as bar heights
/// in the WaveformSeeker. A track with no stored profile yields no DTO (the frontend falls
/// back to a flat seekbar), so this type never represents "absent" — only a present profile.
/// </summary>
public class WaveformProfileDto
{
public int BucketCount { get; set; }
public string Data { get; set; } = string.Empty;
}
+15
View File
@@ -0,0 +1,15 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
/// vault key used to trigger generation for a missing profile.
/// </summary>
public class WaveformStatusDto
{
public long TrackId { get; set; }
public string EntryKey { get; set; } = string.Empty;
public string TrackName { get; set; } = string.Empty;
public bool HasProfile { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using DeepDrftModels.Enums;
using Models.Entities;
namespace DeepDrftModels.Entities;
// The release-cardinal half of the normalized track schema (Phase 8 §8.0). One ReleaseEntity is
// shared by every track on the same album; track-cardinal data stays on TrackEntity, which points
// back here via a nullable ReleaseId (singles and loose tracks have no release context).
//
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
public class ReleaseEntity : BaseEntity, IEntity
{
public required string Title { get; set; }
public required string Artist { get; set; }
public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
public long? CreatedByUserId { get; set; }
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
}
+8 -6
View File
@@ -5,14 +5,16 @@ namespace DeepDrftModels.Entities;
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses
// declare it explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
//
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields (Artist, Album→Title, Genre,
// ReleaseDate, ImagePath, ReleaseType, CreatedByUserId) live on ReleaseEntity, reached via the
// nullable Release navigation; ReleaseId is null for singles and loose tracks.
public class TrackEntity : BaseEntity, IEntity
{
public required string EntryKey { get; set; }
public required string TrackName { get; set; }
public required string Artist { get; set; }
public string? Album { get; set; }
public string? Genre { get; set; }
public DateOnly? ReleaseDate { get; set; }
public string? ImagePath { get; set; }
public long? CreatedByUserId { get; set; }
public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1;
public long? ReleaseId { get; set; }
public ReleaseEntity? Release { get; set; }
}
+9
View File
@@ -0,0 +1,9 @@
namespace DeepDrftModels.Enums;
/// <summary>The commercial release format of a track's parent release.</summary>
public enum ReleaseType
{
Single,
EP,
Album
}
+28 -11
View File
@@ -10,15 +10,21 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance). **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).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date).
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints).
- `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).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
- `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.
- `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.
- `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -27,9 +33,10 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `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, offset)``Stream`.
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
- `ViewModels/`: Component state.
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
- `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
@@ -54,23 +61,33 @@ Both are configured with JSON serializer settings (case-insensitive property mat
### Implementation
- `AudioPlayerService` (abstract base): Lifecycle. Stores current track, playback state, volume. `SelectTrack` throws `NotSupportedException` (buffered path is dead); derived classes override `SelectTrackStreaming`.
- `StreamingAudioPlayerService` (production): Constructor takes `TrackMediaClient`, `AudioInteropService`, logger. `SelectTrackStreaming`:
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: 0)`.
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (1664 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId)`, which returns a response object including `ContentType` (e.g., `audio/wav`, `audio/mpeg`, `audio/flac`).
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (1664 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` (JS interop call with format hint).
3. TypeScript `StreamDecoder` is format-agnostic; delegates format-specific header parsing and chunked decoding to the appropriate `IFormatDecoder` implementation (e.g., `WavFormatDecoder` for WAV, TBD MP3/FLAC decoders for other formats). Decoder parses header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
4. `PlaybackScheduler` schedules buffers on Web Audio `AudioContext`.
5. Playback starts as soon as a configurable min buffer count is queued.
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: byteOffset)`. Server's `WavOffsetService` synthesises a new 44-byte WAV header and streams from the offset. Player tears down and re-initialises decoder for the new stream.
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, byteOffset)` with a file-absolute byte offset. Client sends `Range: bytes={offset}-`; server responds 206 with raw bytes (same format as original file); decoder retains the parsed header and feeds the continuation directly into the decode pipeline.
### Interop bridge
- `AudioInteropService.CreatePlayerAsync` polls `DeepDrftAudio.isReady()` before proceeding; `index.ts` sets `ready = true` after attaching the API to `window`. This guards against slow WASM boot / cache misses.
- `AudioInteropService.ProcessStreamingChunkAsync(chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(chunk)` and awaits the Promise.
- `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(contentType, chunk)` and awaits the Promise. The `contentType` parameter is passed through to the format-decoder factory.
- `AudioInteropService` also manages callback registrations for progress (fired by `PlaybackScheduler`), end-of-playback (fired by `PlaybackScheduler`), and spectrum data (fired by `SpectrumAnalyzer`). Each callback is a `DotNetObjectReference` to a delegate.
### Format decoders (TypeScript)
New modules in `DeepDrftPublic/Interop/audio/`:
- `IFormatDecoder.ts`: Interface. Defines contract for format-specific decoders: `parseHeader(chunk, offset)` → header metadata; `decodeChunk(chunk, offset)``AudioBuffer`; `getAlignedSegmentSize(chunk, offset, rawData?)` → frame-aligned segment boundary (optional `rawData` parameter for format-specific frame-boundary scanning).
- `WavFormatDecoder.ts`: Concrete WAV implementation (active). Parses RIFF/WAVE structure, fmt and data chunks. All WAV-specific byte-parsing logic lives here. Exported as the default WAV decoder.
- `Mp3FormatDecoder.ts`: Concrete MP3 implementation (implemented, not yet wired). Implements `IFormatDecoder` for MP3: ID3v2 skip, MPEG Layer III frame-sync + header decode (MPEG1/2/2.5), Xing/Info/VBRI VBR-header detection (frame count + 100-entry TOC for seek), CBR frame-aligned segment sizing, VBR TOC-interpolation seek (`calculateByteOffset`), zero-copy `wrapSegment` (raw MP3 frames are self-contained). CBR sub-frame tail guard prevents over-read.
- `FlacFormatDecoder.ts`: Concrete FLAC implementation (implemented, not yet wired). Implements `IFormatDecoder` for FLAC: scans all metadata blocks (STREAMINFO mandatory, SEEKTABLE optional), extracts 20-bit sample rate / 3-bit channels / 5-bit bitsPerSample / 36-bit total-samples from bit-packed STREAMINFO, builds 38-byte synthetic STREAMINFO block for per-segment wrapping, binary-search SEEKTABLE for seek. `wrapSegment` prepends `fLaC + STREAMINFO` to each audio segment so `decodeAudioData` sees a valid FLAC stream. `getAlignedSegmentSize` scans backward through peek bytes for the `0xFF/(0xF8|0xF9)` FLAC frame sync so each segment ends on a real frame boundary.
`StreamDecoder.ts` remains the orchestrator — it accepts the first chunk, selects the right format decoder via factory (based on `contentType`), peeks candidate bytes before calling `getAlignedSegmentSize` (non-destructive read), passes them as `rawData`, and uses zero-copy `subarray` for the actual segment. It delegates all format-specific work to the decoder and chains subsequent chunks through the same decoder instance. `Mp3FormatDecoder` and `FlacFormatDecoder` are implemented modules but not yet wired into `AudioPlayer.createFormatDecoder` factory (Wave 3 pending).
### Component integration
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Minimize-state mutations (`Expand`, `ToggleMinimized`, `Close`) all route through a private `SetMinimized(bool value)` mutator, which guards no-ops, fires the `OnMinimized` callback, and calls `StateHasChanged()`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and clears `_selectedTrack` when `!PlayerService.IsLoaded` (covers Stop, Unload, and end-of-track).
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and calls `StateHasChanged()` unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived from `PlayerService.CurrentTrack` and `PlayerService.IsPlaying` (no local `_selectedTrack` field).
## Dark-mode plumbing
+93 -1
View File
@@ -20,7 +20,10 @@ public class TrackClient
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
{
var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(),
@@ -33,6 +36,15 @@ public class TrackClient
if (sortDescending)
queryArgs["sortDescending"] = "true";
if (!string.IsNullOrEmpty(searchText))
queryArgs["q"] = searchText;
if (!string.IsNullOrEmpty(album))
queryArgs["album"] = album;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}");
@@ -50,4 +62,84 @@ public class TrackClient
? ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged)
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response");
}
/// <summary>
/// Fetches a random track from the public library. A 404 means the library is empty — a valid
/// state, not an error — so it returns a pass result with a null value. Any other non-success
/// status is a genuine failure.
/// </summary>
public async Task<ApiResult<TrackDto?>> GetRandom()
{
var response = await _http.GetAsync("api/track/random");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return ApiResult<TrackDto?>.CreatePassResult(null);
if (!response.IsSuccessStatusCode)
return ApiResult<TrackDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return track is not null
? ApiResult<TrackDto?>.CreatePassResult(track)
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<ReleaseDto>>> GetAlbums()
{
var response = await _http.GetAsync("api/track/albums");
if (!response.IsSuccessStatusCode)
return ApiResult<List<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var releases = JsonSerializer.Deserialize<List<ReleaseDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return releases is not null
? ApiResult<List<ReleaseDto>>.CreatePassResult(releases)
: ApiResult<List<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
{
var response = await _http.GetAsync("api/track/genres");
if (!response.IsSuccessStatusCode)
return ApiResult<List<GenreSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var genres = JsonSerializer.Deserialize<List<GenreSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return genres is not null
? ApiResult<List<GenreSummaryDto>>.CreatePassResult(genres)
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
{
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
if (!response.IsSuccessStatusCode)
return ApiResult<TrackDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return track is not null
? ApiResult<TrackDto>.CreatePassResult(track)
: ApiResult<TrackDto>.CreateFailResult("Failed to deserialize response");
}
}
@@ -1,3 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
using NetBlocks.Models;
@@ -8,15 +12,26 @@ public class TrackMediaResponse : IDisposable
public Stream Stream { get; }
public long ContentLength { get; }
public TrackMediaResponse(Stream stream, long contentLength)
/// <summary>
/// The response media type (e.g. "audio/wav", "audio/mpeg"). Drives format-decoder
/// selection on the JS side. Falls back to "audio/wav" when the server omits the header.
/// </summary>
public string ContentType { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
ContentType = contentType;
_response = response;
}
public void Dispose()
{
Stream?.Dispose();
_response?.Dispose();
}
}
@@ -30,10 +45,12 @@ public class TrackMediaClient
}
/// <summary>
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
/// navigation or seek-replacement aborts the in-flight server connection rather
/// than leaving the server draining bytes into a dead socket.
/// Fetches the WAV 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.
/// </summary>
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
string trackId,
@@ -42,23 +59,60 @@ public class TrackMediaClient
{
try
{
// Build URL with optional offset parameter
var url = byteOffset > 0
? $"api/track/{trackId}?offset={byteOffset}"
: $"api/track/{trackId}";
// Same URL for every seek — 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);
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength ?? 0;
// 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";
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
// 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));
}
catch (Exception e)
{
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
}
}
/// <summary>
/// Fetches a track's stored waveform loudness profile. A 404 means no profile is stored
/// (existing tracks predate profiling, or computation failed at upload); callers treat that
/// as "render a flat seekbar" rather than an error, so it surfaces as a fail result with a
/// stable message rather than throwing.
/// </summary>
public async Task<ApiResult<WaveformProfileDto>> GetWaveformProfileAsync(string trackId, CancellationToken cancellationToken = default)
{
try
{
var response = await _http.GetAsync($"api/track/{trackId}/waveform", cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("No waveform profile available");
}
response.EnsureSuccessStatusCode();
var profile = await response.Content.ReadFromJsonAsync<WaveformProfileDto>();
if (profile is null)
{
return ApiResult<WaveformProfileDto>.CreateFailResult("Waveform profile response was empty");
}
return ApiResult<WaveformProfileDto>.CreatePassResult(profile);
}
catch (Exception e)
{
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
}
}
}
@@ -1,117 +1,46 @@
@if (_isMinimized)
{
<div class="minimized-dock d-flex align-center justify-center"
@onclick="@ToggleMinimized">
<MudIconButton Icon="@Icons.Material.Filled.ExpandLess"
Color="Color.Primary"
Size="Size.Large"
Class="minimized-button"
OnClick="@ToggleMinimized"/>
<div class="minimized-dock">
<LevelMeterFab OnClick="@ToggleMinimized" />
</div>
}
else
else
{
<div class="player-outer-container d-flex flex-column">
<div class="@PlayerModeClass d-flex flex-column" @ref="_playerRoot">
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
<div class="player-backdrop pa-3">
@if (_isDesktop)
{
@* Desktop Layout *@
<div class="d-flex align-center gap-3">
<div class="controls-left d-flex flex-column align-center gap-2">
<div class="d-flex align-center gap-1">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
</div>
<MudPaper Elevation="8" Class="player-surface pa-3">
<div class="d-flex flex-column flex-grow-1">
<div class="seekbar-flex mx-3"
@onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
<div class="player-layout">
<PlayerTransportZone IsLoaded="IsLoaded"
CanPlay="CanPlay"
IsLoading="IsLoading"
IsStreaming="IsStreaming"
LoadProgress="LoadProgress"
DisplayTime="DisplayTime"
Duration="Duration"
Fixed="Fixed"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"
Class="transport-zone"/>
<div class="volume-right">
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
<div class="meta-zone">
<TrackMetaLabel Track="CurrentTrack"/>
</div>
}
else
{
@* Mobile Layout *@
<div>
<div class="d-flex align-center justify-space-between mb-3">
<div class="d-flex align-center gap-2">
<PlayerControls IsPlaying="IsPlaying"
IsLoaded="IsLoaded"
TogglePlayPause="@TogglePlayPause"
Stop="@Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</div>
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
</div>
<div class="d-flex flex-column flex-grow-1">
<div @onpointerdown="OnSeekStart"
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
<MudSlider T="double"
Min="0"
Max="@(Duration ?? 0D)"
Step="0.1"
Value="@DisplayTime"
ValueChanged="@OnSeekChange"
Immediate="true"
Disabled="@(!CanSeek)"/>
</div>
<SpectrumVisualizer />
</div>
</div>
}
@* Control Buttons - positioned absolutely like original *@
<div class="player-controls d-flex align-center justify-center gap-1">
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
Color="Color.Secondary"
Size="Size.Small"
OnClick="@ToggleMinimized"/>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Color="Color.Secondary"
Size="Size.Small"
OnClick="@Close"/>
<PlayerSeekZone OnSeekStart="@OnSeekStart"
OnSeekEnd="@OnSeekEnd"
OnSeekChange="@OnSeekChange"
Class="seek-zone"/>
</div>
</div>
@* Minimize / close — positioned absolutely top-right *@
@if (!Fixed)
{
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
}
</MudPaper>
</MudContainer>
@if (!string.IsNullOrEmpty(ErrorMessage))
@@ -124,7 +53,4 @@ else
</MudAlert>
}
</div>
@* Spacer to prevent content overlap *@
<div class="player-spacer"></div>
}
}
@@ -1,29 +1,49 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using MudBlazor.Services;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
private bool _isMinimized = true;
private bool _isSeeking = false;
private double _seekPosition = 0;
private bool _isDesktop = true;
private Guid _viewportSubscriptionId;
private IStreamingPlayerService? _subscribedService;
// 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
// var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static
// value, because the player reflows across breakpoints and grows with the
// error banner.
private ElementReference _playerRoot;
private IJSObjectReference? _spacerModule;
private bool _spacerObserved;
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService?.IsLoading ?? false;
/// <summary>
/// A track is staged when it has been selected as the current track but not yet loaded into
/// the audio context (the embed's pre-gesture state). The first play click loads + plays it.
/// </summary>
private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };
/// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
private bool CanPlay => IsLoaded || IsStaged;
private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
private bool IsPlaying => PlayerService?.IsPlaying ?? false;
private bool IsPaused => PlayerService?.IsPaused ?? false;
private double? Duration => PlayerService?.Duration;
private TrackDto? CurrentTrack => PlayerService?.CurrentTrack;
private double Volume => PlayerService?.Volume ?? 0;
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
private string? ErrorMessage => PlayerService?.ErrorMessage;
@@ -32,15 +52,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
/// <summary>
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
/// This allows seeking even during streaming, including seeking beyond buffered content.
/// </summary>
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
private string PlayerModeClass => Fixed ? "player-fixed" : "player-dock";
protected override void OnParametersSet()
{
if (Fixed)
{
_isMinimized = false;
}
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
// wire our track-selection handler. The provider owns OnStateChanged —
// we intentionally do NOT wrap or replace it. Because the cascade is
@@ -60,18 +80,71 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private async Task Expand()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_isMinimized)
// Only the docked, expanded shape needs a spacer: the Fixed embed is
// already in normal flow, and the minimized FAB floats clear of content.
// Toggle the observer on the minimized/expanded transition only — the
// ResizeObserver itself handles every size change in between.
var shouldObserve = !_isMinimized && !Fixed;
if (shouldObserve == _spacerObserved) return;
var module = await GetSpacerModuleAsync();
if (module is null) return;
if (shouldObserve)
await module.InvokeVoidAsync("observe", _playerRoot);
else
await module.InvokeVoidAsync("unobserve");
_spacerObserved = shouldObserve;
}
private async Task<IJSObjectReference?> GetSpacerModuleAsync()
{
try
{
_isMinimized = false;
StateHasChanged();
return _spacerModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/layout/spacer.js");
}
catch (JSException)
{
// Module failed to load — the spacer falls back to 0px (no overlap
// guard, but the player still works). Nothing actionable here.
return null;
}
}
private async Task Expand() => await SetMinimized(false);
/// <summary>
/// The single assignment site for <see cref="_isMinimized"/>. Guards no-op transitions,
/// fires <see cref="OnMinimized"/> so MainLayout's spacer class stays in sync, and renders
/// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path.
/// The <c>Fixed</c> branch in OnParametersSet intentionally bypasses this — it is a
/// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer.
/// </summary>
private async Task SetMinimized(bool value)
{
if (_isMinimized == value) return;
_isMinimized = value;
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
StateHasChanged();
}
private async Task TogglePlayPause()
{
if (PlayerService == null) return;
// Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on
// the first play click — the user gesture the browser requires before audio can start.
if (IsStaged)
{
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
return;
}
await PlayerService.TogglePlayPause();
}
@@ -112,11 +185,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
PlayerService?.ClearError();
}
private void ToggleMinimized()
{
_isMinimized = !_isMinimized;
StateHasChanged();
}
private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);
private async Task Close()
{
@@ -125,35 +194,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await PlayerService.Unload();
}
if (!_isMinimized)
{
_isMinimized = true;
StateHasChanged();
}
await SetMinimized(true);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
_isDesktop = breakpoint >= Breakpoint.Sm;
_viewportSubscriptionId = Guid.NewGuid();
await BrowserViewportService.SubscribeAsync(
_viewportSubscriptionId,
args =>
{
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
InvokeAsync(StateHasChanged);
},
new ResizeOptions { NotifyOnBreakpointOnly = true },
fireImmediately: true);
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
@@ -161,6 +204,20 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
if (_spacerModule is not null)
{
try
{
// Clear the var so a torn-down player can't strand a spacer height.
await _spacerModule.InvokeVoidAsync("unobserve");
await _spacerModule.DisposeAsync();
}
catch (JSException)
{
// Runtime already gone (navigation/teardown) — nothing to clean up.
}
_spacerModule = null;
}
}
}
@@ -1,7 +1,8 @@
/* Preserve key visual styles while simplifying layout */
/* Geometry, positioning, and animation only.
Colour, surface, and elevation come from MudBlazor theme props. */
/* Player outer container - fixed positioning */
.player-outer-container {
/* Fixed dock to the viewport bottom */
.player-dock {
position: fixed;
bottom: 0;
left: 0;
@@ -11,166 +12,118 @@
margin: 0;
}
/* Player inner container */
.player-inner-container {
padding: 1rem;
padding-bottom: 1.5rem;
.player-fixed {
position: relative;
top: 0;
left: 0;
right: 0;
z-index: 1200;
padding: 0;
margin: 0;
}
/* Custom backdrop blur container */
.player-backdrop {
::deep .player-inner-container {
padding: 0.75rem;
}
/* The visible surface is a MudPaper; scoped CSS only sets geometry + a hairline accent */
::deep .player-surface {
position: relative;
background: var(--deepdrft-theme-background-gray);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 1rem;
border: 2px solid var(--deepdrft-theme-primary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
color: inherit;
transition: all 0.3s ease;
border-radius: 16px;
border: 1px solid var(--mud-palette-primary);
overflow: hidden;
margin-bottom: 1rem;
}
/* Charleston (Light Mode) - Iron frame effect */
:global(.deepdrft-theme-light) .player-backdrop {
background: color-mix(in srgb, var(--charleston-cream) 92%, transparent);
border: 2px solid var(--charleston-iron);
box-shadow: 0 4px 20px color-mix(in srgb, var(--charleston-iron) 20%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--charleston-iron) 5%, transparent);
color: var(--charleston-iron);
}
/* Lowcountry (Dark Mode) - Warm sunset glow effect */
:global(.deepdrft-theme-dark) .player-backdrop {
background: color-mix(in srgb, var(--lowcountry-night) 88%, transparent);
border: 1px solid color-mix(in srgb, var(--lowcountry-coral) 50%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 25%, transparent),
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 15%, transparent),
0 4px 20px rgba(0, 0, 0, 0.4);
color: var(--lowcountry-moonlight);
}
/* Control buttons positioning */
.player-controls {
/* Minimize / close cluster, pinned top-right of the surface */
::deep .player-surface .player-window-controls {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Minimized floating dock with gradient */
/* Minimized floating dock — positioning + hover only; colour from MudFab */
.minimized-dock {
position: fixed;
bottom: 60px;
right: 60px;
bottom: 30px;
right: 30px;
z-index: 1300;
width: 60px;
height: 60px;
border-radius: 50%;
cursor: pointer;
background: linear-gradient(135deg,
var(--deepdrft-theme-primary) 0%,
var(--deepdrft-theme-secondary) 50%,
var(--deepdrft-theme-tertiary) 100%
);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border: 2px solid var(--deepdrft-theme-secondary);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
/* Charleston (Light Mode) - Iron dock */
:global(.deepdrft-theme-light) .minimized-dock {
background: linear-gradient(135deg, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
border: 2px solid var(--charleston-iron);
box-shadow: 0 4px 15px color-mix(in srgb, var(--charleston-iron) 40%, transparent);
}
/* Lowcountry (Dark Mode) - Warm sunset dock */
:global(.deepdrft-theme-dark) .minimized-dock {
background: linear-gradient(135deg, var(--lowcountry-coral) 0%, var(--lowcountry-twilight) 50%, var(--lowcountry-gold) 100%);
border: 2px solid color-mix(in srgb, var(--lowcountry-coral) 60%, transparent);
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 40%, transparent),
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 20%, transparent);
}
.minimized-dock:hover {
transform: scale(1.1);
}
:global(.deepdrft-theme-light) .minimized-dock:hover {
box-shadow: 0 6px 20px color-mix(in srgb, var(--charleston-iron) 50%, transparent);
}
:global(.deepdrft-theme-dark) .minimized-dock:hover {
box-shadow: 0 0 30px color-mix(in srgb, var(--lowcountry-coral) 50%, transparent),
0 0 50px color-mix(in srgb, var(--lowcountry-twilight) 30%, transparent);
}
/* Minimized button styles */
.minimized-button {
border-radius: 50% !important;
background: transparent !important;
color: white !important;
transition: all 0.3s ease !important;
box-shadow: none !important;
border: none !important;
width: 48px !important;
height: 48px !important;
}
/* Spacer to prevent content overlap */
.player-spacer {
height: 140px;
width: 100%;
flex-shrink: 0;
}
/* Essential layout adjustments only */
.controls-left {
min-width: 200px;
}
.seekbar-visualizer-container {
flex: 1;
display: flex;
flex-direction: column;
}
.seekbar-flex {
flex: 1;
}
.volume-right {
/*min-width: 140px;*/
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.minimized-dock {
bottom: 15px;
right: 15px;
width: 56px;
height: 56px;
}
.minimized-button {
width: 44px !important;
height: 44px !important;
::deep .player-surface {
margin-bottom: 0.5rem;
}
.player-inner-container {
padding: 0.75rem;
padding-bottom: 1.25rem;
}
/* Unified responsive player layout CSS Grid with named areas redefined per breakpoint.
The metadata (meta-zone) detaches from the waveform (seek-zone) in the mid band, which a
flex order-swap can't express, so each of the four zones is placed by grid-area and the three
shapes are pure template-area swaps no runtime breakpoint subscription. min-width:0 on the
shrinkable centre zones lets the title truncate and the waveform shrink instead of overflowing. */
.player-layout {
display: grid;
align-items: center;
gap: 8px;
/*padding-right: 2.5rem; !* clear the abs-positioned PlayerWindowControls *!*/
}
::deep .transport-zone { grid-area: transport; }
::deep .meta-zone { grid-area: meta; min-width: 0; }
::deep .seek-zone { grid-area: waveform; min-width: 0; }
::deep .volume-zone { grid-area: volume; }
/* Wide (>= 900px): single row transport and volume flank the centre column; the metadata
sits directly under the waveform (transport/volume span both rows, centred). */
@media (min-width: 900px) {
.player-layout {
grid-template-columns: auto minmax(360px, 1fr) auto;
grid-template-areas:
"transport waveform volume"
"transport meta .";
}
.player-backdrop {
border-radius: 1rem;
margin-bottom: 1.25rem;
}
/* Mid (600900px): metadata rides the top row between transport and volume; the waveform gets
the whole bottom row to itself rather than being squeezed beside the metadata. */
@media (min-width: 600px) and (max-width: 899.98px) {
.player-layout {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"transport meta volume"
"waveform waveform waveform";
}
.player-spacer {
height: 160px;
}
/* Narrow (< 600px): transport + volume share the top row; waveform then metadata stack full-width
below the most compressed shape. */
@media (min-width: 420px) and (max-width: 599.98px) {
.player-layout {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"transport . volume"
"waveform waveform waveform"
"meta meta meta";
}
}
}
/* Very Narrow (< 400px): transport + volume share the top row; waveform then metadata stack full-width
below the most compressed shape. */
@media (max-width: 419.98px) {
.player-layout {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"transport . volume"
"waveform waveform waveform"
"meta meta meta";
}
}
@@ -0,0 +1,38 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<button class="lmf-fab-btn" type="button" @onclick="OnClick">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
<defs>
<!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
<linearGradient id="lmf-grad-@(IdSuffix)"
gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
<stop offset="0%" stop-color="#2a883b" />
<stop offset="17%" stop-color="#2a883b" />
<stop offset="23%" stop-color="#2ECC71" />
<stop offset="47%" stop-color="#2ECC71" />
<stop offset="53%" stop-color="#F4C430" />
<stop offset="72%" stop-color="#F4C430" />
<stop offset="78%" stop-color="#FF6B35" />
<stop offset="100%" stop-color="#FF6B35" />
</linearGradient>
<!-- The note silhouette, used to clip the fill rect. -->
<clipPath id="lmf-clip-@(IdSuffix)">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</clipPath>
</defs>
<!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
fill="rgba(255,255,255,0.25)" />
<!-- Clipped fill: a full-width rect revealed through the note shape. -->
<g clip-path="url(#lmf-clip-@(IdSuffix))">
<rect class="lmf-fill-rect"
x="0" width="24"
y="@FillY" height="@FillH"
fill="url(#lmf-grad-@(IdSuffix))" />
</g>
</svg>
</button>
@@ -0,0 +1,146 @@
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
{
[Inject] public required AudioInteropService AudioInterop { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
// Calibration window for true RMS dBFS (AnalyserNode.getFloatTimeDomainData).
// Floor/ceiling define the dB window; the linear map across this 57 dB range places
// a steady dance track (~-14 dBFS) at ~81% fill (upper yellow) and a hot drop
// (~-6 dBFS) at ~95% (deep orange). Attack/release coefficients are by-ear tuning values.
private const double FloorDb = -60.0; // fill = 0%; below this is near-silence (true RMS dBFS)
private const double CeilingDb = -3.0; // fill = 100%; hot peaks on commercial masters (true RMS dBFS)
private const double SilenceFloorDb = -80.0; // matches the analyzer's normalization window
private const double AttackCoefficient = 0.5; // fast rise toward a louder reading
private const double ReleaseCoefficient = 0.12; // slow decay so the column doesn't strobe
private readonly string _instanceId = Guid.NewGuid().ToString();
private bool _isAnimating;
private string? _playerId;
private IStreamingPlayerService? _subscribedService;
private double _smoothedDb = SilenceFloorDb;
private double _fillPercent; // 0..100, the sole render state
private string IdSuffix => _instanceId.Replace("-", "");
private double FillHeight => 24.0 * (_fillPercent / 100.0);
private string FillY => (24.0 - FillHeight).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
private string FillH => FillHeight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
protected override async Task OnParametersSetAsync()
{
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
// OnParametersSet here. Subscribe to the multicast StateChanged side-channel
// so animation state stays correct independent of parent re-renders —
// notably when the bar minimizes while a track is already playing.
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
{
if (_subscribedService != null)
_subscribedService.StateChanged -= OnPlayerStateChanged;
PlayerService.StateChanged += OnPlayerStateChanged;
_subscribedService = PlayerService;
}
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
}
private void OnPlayerStateChanged() => InvokeAsync(async () =>
{
if (_playerId == null && PlayerService is AudioPlayerService baseService)
{
_playerId = baseService.PlayerId;
}
await UpdateAnimationState();
});
private async Task UpdateAnimationState()
{
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
var shouldAnimate = PlayerService.IsPlaying;
if (shouldAnimate && !_isAnimating)
{
await StartAnimation();
}
else if (!shouldAnimate && _isAnimating)
{
await StopAnimation();
}
}
private async Task StartAnimation()
{
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = true;
_smoothedDb = SilenceFloorDb;
_fillPercent = 0;
await AudioInterop.StartLevelAnimationAsync(_playerId, _instanceId, OnLevelData);
}
private async Task StopAnimation()
{
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
_isAnimating = false;
await AudioInterop.StopLevelAnimationAsync(_playerId, _instanceId);
// Drop the column to empty so only the dim silhouette remains.
if (_fillPercent != 0)
{
_fillPercent = 0;
await InvokeAsync(StateHasChanged);
}
_smoothedDb = SilenceFloorDb;
}
private Task OnLevelData(double db)
{
// db is true RMS dBFS from getFloatTimeDomainData; -Infinity on silence.
var instantDb = double.IsNegativeInfinity(db) || double.IsNaN(db)
? SilenceFloorDb
: Math.Max(db, SilenceFloorDb);
// Attack-fast / release-slow envelope so the column doesn't strobe at 30fps.
var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
_smoothedDb += (instantDb - _smoothedDb) * coeff;
// Linear map of smoothed dB onto a 0-100 fill across the [floor, ceiling] window.
var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);
// Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
if (Math.Abs(next - _fillPercent) >= 0.5)
{
_fillPercent = next;
InvokeAsync(StateHasChanged);
}
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_subscribedService != null)
{
_subscribedService.StateChanged -= OnPlayerStateChanged;
_subscribedService = null;
}
await StopAnimation();
}
}
@@ -0,0 +1,35 @@
/* Circular FAB matching MudFab Size.Large (56px) with MudBlazor primary palette */
.lmf-fab-btn {
width: 72px;
height: 72px;
border-radius: 50%;
border: none;
cursor: pointer;
background-color: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0px 3px 5px -1px rgba(0,0,0,.2),
0px 6px 10px 0px rgba(0,0,0,.14),
0px 1px 18px 0px rgba(0,0,0,.12);
transition: box-shadow 150ms ease-out;
padding: 0;
}
.lmf-fab-btn:hover {
box-shadow: 0px 7px 8px -4px rgba(0,0,0,.2),
0px 12px 17px 2px rgba(0,0,0,.14),
0px 5px 22px 4px rgba(0,0,0,.12);
}
.lmf-fab-btn:focus-visible {
outline: 2px solid var(--mud-palette-primary);
outline-offset: 3px;
}
/* Fill motion is driven by C#-computed SVG geometry, not CSS — no transition here. */
.lmf-icon {
width: 48px;
height: 48px;
}
@@ -1,12 +1,17 @@
<div class="player-buttons">
<MudIconButton Icon="@GetPlayIcon()"
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<PlayStateIcon Size="Size.Large"
Color="Color.Primary"
Size="Size.Large"
OnClick="@TogglePlayPause"
Disabled="!IsLoaded"/>
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
OnClick="@Stop"
Disabled="!IsLoaded"/>
</div>
Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/>
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary"
Size="Size.Large"
OnClick="@Stop"
Disabled="!IsLoaded"/>
}
</MudStack>
@@ -1,16 +1,19 @@
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class PlayerControls : ComponentBase
{
[Parameter] public required bool IsPlaying { get; set; }
[Parameter] public required bool IsLoaded { get; set; }
/// <summary>
/// Whether the play button is enabled. Distinct from <see cref="IsLoaded"/> so a staged-but-
/// unloaded track (embed pre-gesture state) can still be played: the click loads it. Stop stays
/// gated on <see cref="IsLoaded"/>.
/// </summary>
[Parameter] public bool CanPlay { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public required EventCallback TogglePlayPause { get; set; }
[Parameter] public required EventCallback Stop { get; set; }
private string GetPlayIcon()
{
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
}
}
}
@@ -1,8 +0,0 @@
/* PlayerControls Component Styles */
/* Button spacing and alignment */
.player-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -0,0 +1,8 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" Spacing="1" Class="@Class">
<WaveformSeeker OnSeekStart="OnSeekStart"
OnSeekChange="OnSeekChange"
OnSeekEnd="OnSeekEnd"
Class="seek-waveform"/>
</MudStack>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
/// <summary>
/// Centre zone of the player: the <see cref="WaveformSeeker"/>. The seeker owns the pointer-gesture
/// seek logic and reads playback state off the cascaded player service directly; this zone just
/// forwards the seek callbacks up to <see cref="AudioPlayerBar"/> (whose wiring is unchanged).
/// The now-playing metadata (<see cref="TrackMetaLabel"/>) is a sibling zone in the grid, not nested
/// here, so the responsive layouts can place it independently of the waveform.
/// </summary>
public partial class PlayerSeekZone : ComponentBase
{
[Parameter] public EventCallback OnSeekStart { get; set; }
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -0,0 +1,20 @@
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
<MudStack Row="false" AlignItems="AlignItems.Center" Spacing="1" Class="@Class">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<PlayerControls IsLoaded="IsLoaded"
CanPlay="CanPlay"
Fixed="Fixed"
TogglePlayPause="TogglePlayPause"
Stop="Stop"/>
@if (IsLoading && !IsStreaming)
{
<MudProgressCircular Color="Color.Tertiary"
Size="Size.Small"
Max="1D"
Value="@LoadProgress"
Indeterminate="@(LoadProgress == 0)"/>
}
</MudStack>
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
</MudStack>
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class PlayerTransportZone : ComponentBase
{
[Parameter] public bool IsLoaded { get; set; }
[Parameter] public bool CanPlay { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public bool IsStreaming { get; set; }
[Parameter] public double LoadProgress { get; set; }
[Parameter] public double DisplayTime { get; set; }
[Parameter] public double? Duration { get; set; }
[Parameter] public bool Fixed { get; set; } = false;
[Parameter] public EventCallback TogglePlayPause { get; set; }
[Parameter] public EventCallback Stop { get; set; }
[Parameter] public string? Class { get; set; }
}
@@ -0,0 +1,4 @@
/* Stable minimum width so the transport cluster doesn't reflow */
.transport-zone {
min-width: 180px;
}

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