834 Commits

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

DeepDrftPublic audio JS is unaffected because UseStaticFiles() serves
that startup project's physical wwwroot/ directly, bypassing the manifest.
The RCL has no such bypass, so its compiled JS must be present at
manifest-generation time, which requires tracking it in git.
2026-06-12 06:39:07 -04:00
daniel-c-harvey 528b904d72 Merge branch 'cms-bug-fixes' into dev 2026-06-12 06:33:24 -04:00
daniel-c-harvey 0448711082 fix: CMS image proxy + partial unique index for soft-deleted releases 2026-06-12 06:27:34 -04:00
daniel-c-harvey dd30d57838 Merge branch 'tsconfig-publish-fix' into dev
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m19s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m50s
Deploy DeepDrftManager / Deploy (push) Successful in 1m23s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m27s
2026-06-11 20:58:37 -04:00
daniel-c-harvey 70f110bed7 fix: use Content Update to suppress tsconfig.json from publish output 2026-06-11 20:55:43 -04:00
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
daniel-c-harvey f4e39c96fd Merge branch 'creds-env' into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m26s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m25s
2026-06-04 14:01:22 -04:00
daniel-c-harvey c49f28e619 fix: wire credential files into service environment/ dirs on setup and deploy 2026-06-04 14:01:18 -04:00
daniel-c-harvey b58bcd8398 Home Page Normalization 2026-06-04 14:01:03 -04:00
daniel-c-harvey a54b0a8f8e Merge branch 'artifact-v3' into dev
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 1m57s
Deploy DeepDrftManager / Build & Publish (push) Successful in 58s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m10s
Deploy DeepDrftAPI / Deploy (push) Failing after 1m26s
Deploy DeepDrftManager / Deploy (push) Successful in 1m28s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-04 13:34:07 -04:00
daniel-c-harvey 65426a6c67 ci: downgrade upload-artifact and download-artifact from v4 to v3 in all three deploy workflows 2026-06-04 13:34:04 -04:00
daniel-c-harvey 6143d9afef Workflow Triggers
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Failing after 1m36s
Deploy DeepDrftManager / Build & Publish (push) Failing after 55s
Deploy DeepDrftPublic / Deploy (push) Has been cancelled
Deploy DeepDrftPublic / Build & Publish (push) Has been cancelled
Package install tarball / package (push) Successful in 4s
Deploy DeepDrftAPI / Deploy (push) Has been skipped
Deploy DeepDrftManager / Deploy (push) Has been skipped
2026-06-04 13:29:32 -04:00
daniel-c-harvey 690631ef9b Merge branch 'installer-ports' into dev 2026-06-04 13:27:03 -04:00
daniel-c-harvey dfd6d33142 feat(deploy): prompt for service ports at install time; replace hardcoded 5000/5001/5002 with __PORT_*__ placeholders 2026-06-04 13:27:00 -04:00
daniel-c-harvey c14c032081 Workflow Trigger
Package install tarball / package (push) Successful in 4s
2026-06-04 12:26:11 -04:00
daniel-c-harvey 487dcea5c1 Merge branch 'gitattributes' into dev 2026-06-04 12:18:36 -04:00
daniel-c-harvey 0d9c92971c chore: add .gitattributes to enforce LF line endings for shell scripts, YAML, systemd units, and nginx configs 2026-06-04 12:18:33 -04:00
473 changed files with 58459 additions and 2996 deletions
+12
View File
@@ -0,0 +1,12 @@
# Default: normalize all text files to LF on commit
* text=auto
# Force LF for files that must be LF on Linux hosts
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.service text eol=lf
*.conf text eol=lf
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
+2 -12
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.
@@ -71,7 +62,7 @@ jobs:
run: tar -czf deepdrft-api.tar.gz -C DeepDrftAPI/publish .
- name: Upload artifacts (archive + bundle)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: deepdrft-api
path: |
@@ -90,7 +81,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: deepdrft-api
path: staging/
@@ -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
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run: tar -czf deepdrft-manager.tar.gz -C DeepDrftManager/publish .
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: deepdrft-manager
path: deepdrft-manager.tar.gz
@@ -49,7 +49,7 @@ jobs:
DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }}
steps:
- name: Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: deepdrft-manager
+6 -2
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
@@ -38,7 +42,7 @@ jobs:
run: tar -czf deepdrft-public.tar.gz -C DeepDrftPublic/publish .
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: deepdrft-public
path: deepdrft-public.tar.gz
@@ -53,7 +57,7 @@ jobs:
DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }}
steps:
- name: Download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: deepdrft-public
+10 -1
View File
@@ -311,4 +311,13 @@ __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
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
# gitignored TS output is absent when manifest is generated, so absent from publish output.
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
# any added later) ships, rather than maintaining a per-module allowlist.
!DeepDrftShared.Client/wwwroot/js/
!DeepDrftShared.Client/wwwroot/js/**
+19 -14
View File
@@ -8,13 +8,13 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer.
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), crawl-directive endpoints (`GET /robots.txt` and `GET /sitemap.xml`, environment-gated via `IWebHostEnvironment.IsProduction()` directly — server-side only, no PersistentState bridge — served by `CrawlDirectiveController` with pure builders in `Seo/RobotsTxt.cs` and `Seo/SitemapXml.cs`), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `<HeadContent>` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. **Always uncrawlable**: a static `wwwroot/robots.txt` (`Disallow: /`, no env gate) plus a blanket `<meta name="robots" content="noindex,nofollow">` in `Components/App.razor` — defense in depth so the CMS is never indexed regardless of how it is discovered. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
@@ -34,7 +34,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
1. **SQL Database (PostgreSQL)**: Metadata and track info via Entity Framework
- Connection string: Read from `environment/connections.json` via `CredentialTools.ResolvePathOrThrow("connections")` with key `ConnectionStrings:DefaultConnection`.
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `DurationSeconds?`
- Context: `DeepDrftContext` in `DeepDrftData`
2. **FileDatabase**: Custom file-based binary storage system
@@ -70,21 +70,26 @@ 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.
### Theming and dark mode
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DDIcons.cs`.
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
### TypeScript interop, not raw JS
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"``outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
## Development Commands
@@ -112,10 +117,10 @@ dotnet run --project DeepDrftAPI
### Entity Framework (SQL Database)
```bash
# Add migration (from solution root)
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
# Update database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
```
## Key Configuration Files
@@ -123,8 +128,8 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+2 -2
View File
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
- Real per-user accounts (`ApplicationUser` table). No shared password.
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
+2090
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.
+266 -27
View File
@@ -6,15 +6,17 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and 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), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles (512-bucket player-bar seeker + per-track high-res visualizer datum), and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (session hero-image upload; mix waveform is a caller-less legacy delegate — the track-cardinal `GET api/track/{entryKey}/waveform/high-res` is the live fetch path). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
## What lives here now (only)
- `Program.cs`, `Startup.cs`: HTTP host config, DI wiring, middleware setup, port binding. AuthBlocks startup: `AddAuthBlocks`, `UseAuthBlocksStartupAsync`, `MapAuthBlocks`, authentication/authorization middleware.
- `Services/UnifiedTrackService.cs`: Host-internal orchestrator. Coordinates vault write + SQL persist for upload (`UploadAsync`), and SQL delete + vault remove for delete (`DeleteAsync`).
- `Controllers/TrackController.cs`: Seven track endpoints (see below).
- `Services/UnifiedReleaseService.cs`: Host-internal orchestrator. Coordinates release mutations (mix waveform compute + store, session hero-image upload + link).
- `Controllers/TrackController.cs`: Track endpoints (see below).
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
@@ -22,21 +24,121 @@ 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)
## The endpoint surface
### 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`).
### GET api/track/albums (unauthenticated)
Returns a list of all releases with per-release track counts. Public browse data, same auth posture as `GET api/track/page`.
- **Response**: `List<ReleaseDto>` where each release carries its title, artist, genre, release date, medium, and track count.
- Returns 200 with the release list on success. Returns 500 on query error.
### GET api/track/genres (unauthenticated)
Returns distinct non-null genres with per-genre track counts. Public browse data, same auth posture as `GET api/track/page`.
- **Response**: A collection of genre strings with track counts.
- Returns 200 on success. Returns 500 on query error.
### GET api/track/random (unauthenticated)
Picks one track at random from the full library and returns its metadata. Public, same auth posture as `GET api/track/page`.
- **Response**: A single `TrackDto` selected uniformly at random.
- Returns 200 on success. Returns 404 if the library is empty (a valid state). Returns 500 on query error.
### GET api/track/{trackId}/waveform (unauthenticated)
Returns the stored waveform loudness profile for a track as base64-encoded bytes. Public listener data, same auth posture as `GET api/track/{trackId}`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- **Response**: `WaveformProfileDto` with `BucketCount` (number of loudness buckets) and `Data` (base64-encoded byte array).
- Returns 200 on success. Returns 404 if no profile is stored (existing tracks may predate profiling, or computation failed at upload — the frontend falls back to a flat seekbar). Returns 500 on vault error.
### POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
Admin backfill: computes and stores a waveform profile for an existing track from its vault audio.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
- Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails.
### GET api/track/{trackId}/waveform/high-res (unauthenticated)
Track-cardinal high-res datum fetch. Returns the per-track duration-derived high-res waveform datum (~333 samples/sec) from the `track-waveforms` vault. This is the live read path for the `WaveformVisualizer` bridge — the release-level mix waveform endpoint is a caller-less legacy delegate.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if no high-res datum is stored (graceful — not-yet-backfilled tracks fall back to no visualizer data). Returns 500 on vault error.
### POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
Server-side trigger: compute and store the per-track high-res datum for any track from its vault audio, keyed by `EntryKey` in the `track-waveforms` vault. Drives the CMS per-row "Generate high-res" action and the CMS batch backfill action. Generalised off Mix-only in Phase 12.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`.
- Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure.
### GET api/track/meta/by-key/{entryKey} (unauthenticated)
Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable through the public proxy.
- **Route parameter `entryKey`** (string): the TrackEntity.EntryKey.
- **Response**: `TrackDto` for the matching track.
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/track/waveform-status ([ApiKeyAuthorize])
Admin backfill view: returns every track with flags indicating whether each waveform type is stored. Used by the CMS track list to flag tracks needing waveform computation.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault).
- Returns 200 on success. Returns 500 on query error.
### POST api/track/duration/backfill ([ApiKeyAuthorize])
Admin backfill: for every track whose `DurationSeconds` SQL column is still null, reads the `AudioBinary.Duration` from the vault and writes it to SQL. Idempotent — a re-run only touches still-null rows; rows that already have a value are skipped.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- No request body.
- Calls `UnifiedTrackService.BackfillDurationsAsync`. Lives on `TrackController` in the literal-route block (before `{trackId}` routes, so the segment is never treated as a trackId).
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
- Returns 200 on success. Returns 500 if the backfill operation fails.
### GET api/track/release/exists ([ApiKeyAuthorize])
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Query parameters**:
- `title` (string, required): the release title to check.
- `artist` (string, required): the artist name to check.
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. Returns 500 on query error.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- Calls `ITrackService.DeleteRelease`.
- Returns 200 on success. Returns 500 on deletion error.
### PUT api/track/{trackId} ([ApiKeyAuthorize])
@@ -51,21 +153,25 @@ 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.
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -76,17 +182,31 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
- Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey.
- Returns 200 on success, 404 if track not found, 500 if deletion fails.
### GET api/track/page ([ApiKeyAuthorize])
### POST api/track/{id:long}/replace-audio ([ApiKeyAuthorize])
**Authenticated endpoint.** Paged metadata list from SQL. Used by CMS track browser.
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) as `multipart/form-data` and replaces the existing track's vault bytes in place, preserving the track id, `EntryKey`, SQL row (metadata/release/position), and release membership. Both waveform datums (512-bucket seeker profile + high-res visualizer datum) are regenerated after the swap; waveform regen failure is logged and swallowed — it does not fail the replace.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
### GET api/track/page (unauthenticated)
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
- **Query parameters**:
- `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): tracks per page.
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
- `sortDescending` (bool, optional, default false): sort direction.
- Calls `ITrackService.GetPaged` (via DI), which is actually `TrackManager` from `DeepDrftData`.
- `q` (string, optional): search text filter (matches track name / artist).
- `album` (string, optional): album title filter.
- `genre` (string, optional): genre filter.
- `releaseId` (long?, optional): release ID filter (authoritative join; preferred over album title).
- Calls `ITrackService.GetPaged` with optional `TrackFilter` (null if all filter params are empty).
- Returns 200 with `PagedResult<TrackDto>` JSON (`Items`, `TotalCount`, `PageNumber`, `PageSize`). Returns 500 on query error.
### GET api/track/meta/{id:long} ([ApiKeyAuthorize])
@@ -104,9 +224,124 @@ 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`.
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
- **Body**: `UpdateTrackMetadataRequest` with fields:
- `TrackName` (string, required)
- `Artist` (string, required)
- `Album` (string?, optional)
- `Genre` (string?, optional)
- `ReleaseDate` (DateOnly?, optional)
- `ImagePath` (string?, tri-state: null = no change, "" = clear, value = set)
- `ReleaseType` (ReleaseType?, optional): updates the linked release if present; null = no change.
- `Medium` (ReleaseMedium?, optional): updates the linked release if present; null = no change. When `Medium` is set to non-`Cut`, also resets `ReleaseType` to `Single` (the DB default) to avoid stale studio-format values.
- `TrackNumber` (int?, optional): track position within the release; validated > 0 when provided.
- Looks up SQL row by ID, updates the provided fields, and persists via `ITrackService.Update`. Track-cardinal fields (`TrackName`, `TrackNumber`) update the track row; release-cardinal fields (`Artist`, `Album`, `Genre`, `ReleaseDate`, `ImagePath`, `ReleaseType`, `Medium`) update the linked release (if present; loose tracks ignore these).
- Returns 200 on success. Returns 400 if `TrackNumber` ≤ 0 (when provided). Returns 404 if track not found. Returns 500 on update error.
## The image endpoints (two endpoints)
### 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`).
## The release endpoints
### GET api/release (unauthenticated)
Paged release list, optionally filtered to one medium. Public browse data, same auth posture as `GET api/track/page`.
- **Query parameters**:
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). If provided, only releases of that medium are returned; the matching medium's metadata satellite is populated, others are null.
- `page` (int, optional, default 1): 1-based page number.
- `pageSize` (int, optional, default 20): releases per page.
- `sortColumn` (string, optional): sort field (typically `"Title"`).
- `sortDescending` (bool, optional, default false): sort direction.
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
### GET api/release/{entryKey} (unauthenticated)
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
### GET api/release/{entryKey}/mix/waveform (unauthenticated — caller-less legacy delegate)
Legacy endpoint: formerly served the high-res waveform datum for a Mix release from the `mix-waveforms` vault. **No longer called by the client** — the live fetch path is now the track-cardinal `GET api/track/{trackId}/waveform/high-res` (Phase 12). The endpoint is retained in the API but has no active callers. `UnifiedReleaseService.TriggerMixWaveformAsync` now delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` (the same shared seam used by the upload path and the generalized CMS generate action).
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize])
Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body.
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- Calls `UnifiedReleaseService.TriggerMixWaveformAsync`.
- Returns 200 on success. Returns 404 if the release is missing, is not a Mix, has no track, or the track audio is not stored. Returns 500 on compute/storage failure.
### POST api/release/{id:long}/session/hero-image ([ApiKeyAuthorize])
Stores a hero image in the `images` vault and links it via `SessionMetadata.HeroImageEntryKey`. The release must be a Session medium (enforced in the service).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL release ID.
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
- Validates MIME type (rejects unsupported types with `.bin` sentinel). Calls `UnifiedReleaseService.SetHeroImageAsync`.
- Returns 200 on success. Returns 400 for missing file or unsupported MIME type. Returns 404 if release not found. Returns 500 on processing or vault failure.
## The stats endpoints
### GET api/stats/home (unauthenticated)
Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A single read returns everything the three cards need so the client makes one round-trip. Public, same auth posture as `GET api/track/page`.
- **Response**: `HomeStatsDto` with:
- `CutTrackCount` (int): total non-deleted tracks on Cut-medium releases.
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (zero-suppressed server-side).
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0).
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's bucket columns (`PartialCount + SampledCount + CompleteCount`), all-time (Phase 16). Zero until the play-telemetry migration is applied.
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` across all play events, all-time (Phase 16). Zero until the migration is applied.
- `StatsController` injects **both** `ITrackService` (track-domain aggregation — Cuts/Mixes cards) and `IEventService` (event-domain aggregation — Plays card). Neither domain reaches into the other's tables; the controller is the thin composition seam. Track-domain aggregation comes from `TrackRepository.GetHomeStatsAsync` via `ITrackService.GetHomeStats`; play/listener figures come from `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16 wave 16.5). Play/listener reads are **best-effort**: a telemetry failure or not-yet-applied migration leaves those fields at 0 rather than failing the whole endpoint with 500.
- Returns 200 on success. Returns 500 if the track-domain aggregation fails.
## The event endpoints (Phase 16 anonymous telemetry)
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
### POST api/event/play (unauthenticated, rate-limited)
Records an anonymous play event. Client sends the track `EntryKey`, a completion bucket, and an optional `anonId` (wave 16.3); server-side release resolution joins track→release at write time (D4). The `anonId` is length-clamped server-side: whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars returns `400` rather than being truncated (truncation would collide distinct listeners).
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete", "anonId": "..." }` (`anonId` optional — omitted when null).
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
### POST api/event/share (unauthenticated, rate-limited)
Records an anonymous share event (a clipboard write from `SharePopover`).
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed", "anonId": "..." }` (`anonId` optional — omitted when null; same length-clamp as the play endpoint).
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values; `anonId` ≤ 64 chars (reject-not-truncate).
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
## ApiKey middleware behaviour
@@ -141,7 +376,10 @@ 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`.
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -164,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
## Configuration files
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
- `Logging`: standard ASP.NET structure.
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
@@ -232,7 +471,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 \
+114
View File
@@ -0,0 +1,114 @@
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DeepDrftAPI.Controllers;
/// <summary>
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
/// </summary>
[ApiController]
[Route("api/event")]
[EnableRateLimiting("events")]
public class EventController : ControllerBase
{
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
// payloads are a track key + an enum, well under 1 KB.
private const int MaxBodyBytes = 1024;
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
private const int MaxAnonIdLength = 64;
private readonly IEventService _eventService;
private readonly ILogger<EventController> _logger;
public EventController(IEventService eventService, ILogger<EventController> logger)
{
_eventService = eventService;
_logger = logger;
}
// POST api/event/play (unauthenticated, rate-limited)
[HttpPost("play")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
{
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
// keep the contract obvious and cover the empty-string key the model binder lets through.
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
return BadRequest("trackEntryKey is required");
if (!Enum.IsDefined(payload.Bucket))
return BadRequest("bucket is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
if (!result.Success)
{
// A telemetry failure must never surface to the listener as an error they can act on, but
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
// beacon ignores the status either way.
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// POST api/event/share (unauthenticated, rate-limited)
[HttpPost("share")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(payload.TargetKey))
return BadRequest("targetKey is required");
if (!Enum.IsDefined(payload.TargetType))
return BadRequest("targetType is invalid");
if (!Enum.IsDefined(payload.Channel))
return BadRequest("channel is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
if (!result.Success)
{
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
// over the column width is rejected (400) rather than truncated, since truncation would collide
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
{
if (string.IsNullOrWhiteSpace(raw))
{
anonId = null;
return true;
}
var trimmed = raw.Trim();
if (trimmed.Length > MaxAnonIdLength)
{
anonId = null;
return false;
}
anonId = trimmed;
return true;
}
}
+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);
}
}
@@ -0,0 +1,191 @@
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Services;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ReleaseController : ControllerBase
{
private readonly IReleaseService _releaseService;
private readonly UnifiedReleaseService _unifiedReleaseService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<ReleaseController> _logger;
public ReleaseController(
IReleaseService releaseService,
UnifiedReleaseService unifiedReleaseService,
WaveformProfileService waveformProfileService,
ILogger<ReleaseController> logger)
{
_releaseService = releaseService;
_unifiedReleaseService = unifiedReleaseService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
// GET api/release?medium=session&q=text&genre=House&page=1&pageSize=20&sortColumn=Title&sortDescending=false (unauth)
// Paged release list, optionally narrowed by medium, free-text search (q), and genre. The matching
// medium's metadata satellite is populated; the others are null. Backs the public /archive browser.
// Public browse data, same auth posture as GET api/track/page.
[HttpGet]
public async Task<ActionResult> GetReleases(
[FromQuery] string? medium = null,
[FromQuery] string? q = null,
[FromQuery] string? genre = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
CancellationToken ct = default)
{
ReleaseMedium? parsedMedium = null;
if (!string.IsNullOrWhiteSpace(medium))
{
if (!Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var m) || !Enum.IsDefined(m))
return BadRequest($"Unrecognised medium: {medium}");
parsedMedium = m;
}
var filter = new ReleaseFilter { SearchText = q, Genre = genre };
var result = await _releaseService.GetPagedAsync(page, pageSize, sortColumn, sortDescending, parsedMedium, filter, ct);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleases failed: {Error}", error);
return StatusCode(500, "Failed to load releases");
}
return Ok(result.Value);
}
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
// constraint). Declared before the shorter "{entryKey}" route for clarity.
//
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
// release-addressed shape.
[HttpGet("{entryKey}/mix/waveform")]
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
{
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release");
}
var release = lookup.Value;
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
{
_logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
return NotFound();
}
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
// compute/storage failure. Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/mix/waveform")]
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
{
var result = await _unifiedReleaseService.TriggerMixWaveformAsync(id, ct);
if (result.Success)
return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal)
|| string.Equals(error, UnifiedReleaseService.MixTrackNoAudioMessage, StringComparison.Ordinal)
|| string.Equals(error, UnifiedReleaseService.MixHasNoTrackMessage, StringComparison.Ordinal))
{
return NotFound();
}
_logger.LogError("GenerateMixWaveform failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, error);
}
// POST api/release/{id}/session/hero-image ([ApiKeyAuthorize], multipart)
// Stores a hero image in the images vault and sets SessionMetadata.HeroImageEntryKey. The release
// must be a Session medium (enforced in the service). Declared before "{id:long}".
[ApiKeyAuthorize]
[HttpPost("{id:long}/session/hero-image")]
[RequestSizeLimit(50_000_000)]
public async Task<ActionResult> UploadSessionHeroImage(
long id,
[FromForm] IFormFile? image,
CancellationToken ct = default)
{
if (image is null || image.Length == 0)
return BadRequest("Image file is required");
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
{
_logger.LogWarning("UploadSessionHeroImage rejected: unsupported content type '{ContentType}'", image.ContentType);
return BadRequest($"Unsupported image content type: {image.ContentType}");
}
var result = await _unifiedReleaseService.SetHeroImageAsync(id, image, ct);
if (result.Success)
return Ok();
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
if (string.Equals(error, ReleaseManager.ReleaseNotFoundMessage, StringComparison.Ordinal))
return NotFound();
_logger.LogError("UploadSessionHeroImage failed for {ReleaseId}: {Error}", id, error);
return StatusCode(500, error);
}
// GET api/release/{entryKey} (unauthenticated)
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
// route so the segmented route resolves first.
[HttpGet("{entryKey}")]
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
{
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
return StatusCode(500, "Failed to load release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
}
@@ -0,0 +1,59 @@
using DeepDrftData;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class StatsController : ControllerBase
{
private readonly ITrackService _sqlTrackService;
private readonly IEventService _eventService;
private readonly ILogger<StatsController> _logger;
public StatsController(
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
{
_sqlTrackService = sqlTrackService;
_eventService = eventService;
_logger = logger;
}
// GET api/stats/home (unauthenticated)
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
// posture as the other public browse reads (GET api/track/page). The figures span two domains:
// the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain
// figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This
// controller is the thin composition seam that assembles both into one HomeStatsDto — neither
// domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read
// failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card.
[HttpGet("home")]
public async Task<ActionResult> GetHome(CancellationToken ct = default)
{
var trackResult = await _sqlTrackService.GetHomeStats(ct);
if (!trackResult.Success || trackResult.Value is null)
{
var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("GetHome stats failed: {Error}", error);
return StatusCode(500, "Failed to load stats");
}
var stats = trackResult.Value;
var playsResult = await _eventService.GetTotalPlayCount(ct);
if (playsResult is { Success: true })
stats.TotalPlays = playsResult.Value;
else
_logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}",
playsResult.Messages.FirstOrDefault()?.Message);
var listenersResult = await _eventService.GetDistinctListenerCount(ct);
if (listenersResult is { Success: true })
stats.UniqueListeners = listenersResult.Value;
else
_logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}",
listenersResult.Messages.FirstOrDefault()?.Message);
return Ok(stats);
}
}
+566 -112
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,10 @@ 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 UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -29,34 +32,83 @@ public class TrackController : ControllerBase
public TrackController(
DeepDrftContent.TrackContentService trackContentService,
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
WavOffsetService wavOffsetService,
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
_fileDatabase = fileDatabase;
_wavOffsetService = wavOffsetService;
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
private string BuildStagingPath(string uploadExtension) =>
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
// must delete it in a finally block; separating path generation from the copy ensures the finally
// guard fires even when CopyToAsync throws before returning.
private async Task StageUploadAsync(
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
{
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=&releaseId=
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
// q/album/genre/releaseId build an optional TrackFilter; all null → null passthrough (no filtering).
// releaseId is the authoritative release→tracks join (exact match), preferred over album title.
[HttpGet("page")]
public async Task<ActionResult> GetPage(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortColumn = null,
[FromQuery] bool sortDescending = false,
[FromQuery] string? q = null,
[FromQuery] string? album = null,
[FromQuery] string? genre = null,
[FromQuery] long? releaseId = null,
CancellationToken cancellationToken = default)
{
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken);
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre, ReleaseId = releaseId };
var effectiveFilter = filter.IsEmpty ? null : filter;
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
@@ -67,34 +119,188 @@ 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/release/exists?title=...&artist=... ([ApiKeyAuthorize])
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
[ApiKeyAuthorize]
[HttpGet("release/exists")]
public async Task<ActionResult> ReleaseExists(
[FromQuery] string? title,
[FromQuery] string? artist,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return BadRequest("title and artist are both required");
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
return StatusCode(500, "Failed to check release");
}
if (result.Value is null)
return NotFound();
return Ok(result.Value);
}
// GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
[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 flags for whether each waveform datum is stored —
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
// segment is never treated as a trackId.
[ApiKeyAuthorize]
[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);
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
status.Add(new WaveformStatusDto
{
TrackId = track.Id,
EntryKey = track.EntryKey,
TrackName = track.TrackName,
HasProfile = profile is not null,
HasHighRes = highRes is not null,
});
}
return Ok(status);
}
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
// only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
// (before "{trackId}") so the segment is never treated as a trackId.
[ApiKeyAuthorize]
[HttpPost("duration/backfill")]
public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
{
var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurations failed: {Error}", error);
return StatusCode(500, error);
}
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
}
// POST api/track/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
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
// not a buffered allocation.
// RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
// hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
// outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
// limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below
// int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
[ApiKeyAuthorize]
[HttpPost("upload")]
[RequestSizeLimit(1_073_741_824)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
[RequestSizeLimit(2_000_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
[FromForm] IFormFile? wav,
[FromForm] IFormFile? audioFile,
[FromForm] string? trackName,
[FromForm] string? artist,
[FromForm] string? album,
[FromForm] string? genre,
[FromForm] string? description,
[FromForm] string? releaseDate,
[FromForm] string? originalFileName,
[FromForm] long createdByUserId,
[FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber,
[FromForm] long? releaseId,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, 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 +313,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,34 +329,78 @@ 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);
}
// Default to Cut for null/unparseable medium, mirroring the releaseType defensive parse above.
ReleaseMedium parsedMedium;
if (!string.IsNullOrWhiteSpace(medium)
&& Enum.TryParse<ReleaseMedium>(medium, ignoreCase: true, out var rm)
&& Enum.IsDefined(rm))
{
parsedMedium = rm;
}
else
{
parsedMedium = ReleaseMedium.Cut;
if (!string.IsNullOrWhiteSpace(medium))
_logger.LogWarning("UploadTrack: unrecognised medium value '{Value}', defaulting to Cut", medium);
}
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = wav.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
string.IsNullOrWhiteSpace(genre) ? null : genre,
string.IsNullOrWhiteSpace(description) ? null : description,
parsedReleaseDate,
createdByUserId,
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
parsedReleaseType,
parsedMedium,
resolvedTrackNumber,
releaseId,
cancellationToken);
if (!result.Success || result.Value is null)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
// A cardinality or duplicate-release rejection is a well-formed request that violates a
// domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
// The marker is stripped so the client sees only the human-readable detail.
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
}
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
}
return StatusCode(500, error);
}
@@ -163,17 +414,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -198,6 +439,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 +479,51 @@ 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.Description = request.Description;
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;
// Medium is non-null on the release; null in the request means "no change".
if (request.Medium is not null)
{
release.Medium = request.Medium.Value;
// ReleaseType is meaningful only for Cut. When the medium is anything else, reset
// ReleaseType to the DB-level default rather than leaving a stale studio-format value —
// mirroring TrackConverter's read-path nulling of ReleaseType for non-Cut releases. This
// runs after the ReleaseType apply above, so it correctly overrides a contradictory
// ReleaseType sent in the same request alongside a non-Cut medium.
if (request.Medium.Value != ReleaseMedium.Cut)
release.ReleaseType = ReleaseType.Single;
}
}
var update = await _sqlTrackService.Update(track);
if (!update.Success)
@@ -259,87 +561,137 @@ public class TrackController : ControllerBase
return StatusCode(500, error);
}
// POST api/track/{id}/replace-audio ([ApiKeyAuthorize])
// Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
// release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
// vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
// streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
// literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
// resolves to the parameterized "{trackId}" GET.
[ApiKeyAuthorize]
[HttpPost("{id:long}/replace-audio")]
[RequestSizeLimit(2_000_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
public async Task<ActionResult> ReplaceAudio(
long id,
[FromForm] IFormFile? audioFile,
CancellationToken cancellationToken)
{
_logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length);
if (audioFile is null || audioFile.Length == 0)
{
return BadRequest("Audio file is required");
}
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
{
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
return Ok();
}
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to replace audio";
if (string.Equals(error, UnifiedTrackService.TrackNotFoundMessage, StringComparison.Ordinal))
{
return NotFound();
}
_logger.LogError("ReplaceAudio failed for id {Id}: {Error}", id, error);
return StatusCode(500, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "ReplaceAudio failed for id {Id}", id);
return StatusCode(500, "Internal server error");
}
finally
{
DeleteStagingFile(stagingPath);
}
}
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
// 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 +700,108 @@ 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),
});
}
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
// fetches for whatever track is currently playing/selected — the release is only addressing context.
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[HttpGet("{trackId}/waveform/high-res")]
public async Task<ActionResult> GetHighResWaveform(string trackId)
{
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
if (bytes is null)
{
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
return NotFound();
}
return Ok(new WaveformProfileDto
{
BucketCount = bytes.Length,
Data = Convert.ToBase64String(bytes),
});
}
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
// Admin backfill: compute and store a waveform profile for an existing track from its vault
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
// 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();
}
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
[ApiKeyAuthorize]
[HttpPost("{trackId}/waveform/high-res")]
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
{
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
if (audio is null)
{
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
return NotFound();
}
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, trackId, audio.Duration);
if (!stored)
{
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
return StatusCode(500, "Failed to generate high-res waveform datum.");
}
return Ok();
}
[ApiKeyAuthorize]
[HttpPut("{trackId}")]
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
+7 -1
View File
@@ -15,7 +15,13 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.39" />
</ItemGroup>
<ItemGroup>
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
<InternalsVisibleTo Include="DeepDrftTests" />
</ItemGroup>
<ItemGroup>
+466
View File
@@ -0,0 +1,466 @@
###############################################################################
# DeepDrftAPI — Phase 9 smoke tests
# IDE: VS 2022 / Rider / VS Code REST Client
#
# HOW TO USE:
# 1. Set @apiKey below. The real value is in DeepDrftAPI/environment/apikey.json
# under the key "ApiKeySettings.ApiKey". Do NOT commit a real key here.
# 2. Adjust @releaseId, @trackId, and @entryKey to IDs present in your DB.
# 3. Point the multipart upload requests at a real local file before sending.
#
# AUTH NOTE:
# Unauthenticated endpoints have no ApiKey header.
# ApiKey-gated endpoints use header "ApiKey: {{apiKey}}" (header name is literal
# "ApiKey", not "X-Api-Key" — confirmed in ApiKeyAuthenticationMiddleware.cs).
###############################################################################
@host = http://localhost:5003
# REPLACE with value from DeepDrftAPI/environment/apikey.json → ApiKeySettings.ApiKey
@apiKey = REPLACE_WITH_YOUR_API_KEY
# Placeholders — edit these to match IDs in your local DB
@releaseId = 1
@trackId = 1
@entryKey = replace-with-real-entry-key
###############################################################################
# 1. RELEASE READS
###############################################################################
### 1a. List all releases (unauth) — expect 200 PagedResult
# exercises: GET api/release (no medium filter)
GET {{host}}/api/release
Accept: application/json
###
### 1b. List Session releases (unauth) — expect 200, only Session medium returned
# exercises: GET api/release?medium=session
GET {{host}}/api/release?medium=session
Accept: application/json
###
### 1c. List Mix releases (unauth) — expect 200, only Mix medium returned
# exercises: GET api/release?medium=mix
GET {{host}}/api/release?medium=mix
Accept: application/json
###
### 1d. List Cut releases (unauth) — expect 200, only Cut medium returned
# exercises: GET api/release?medium=cut
GET {{host}}/api/release?medium=cut
Accept: application/json
###
### 1e. Bad medium value (unauth) — expect 400 "Unrecognised medium: bogus"
# exercises: GET api/release?medium=bogus → BadRequest branch in ReleaseController
GET {{host}}/api/release?medium=bogus
Accept: application/json
###
### 1f. Single release by id (unauth) — expect 200 ReleaseDto with both metadata navs
# exercises: GET api/release/{id:long}
GET {{host}}/api/release/{{releaseId}}
Accept: application/json
###
###############################################################################
# 2. TRACK READS
###############################################################################
### 2a. Paged track list — all tracks (unauth) — expect 200 PagedResult<TrackDto>
# exercises: GET api/track/page (no filters)
GET {{host}}/api/track/page
Accept: application/json
###
### 2b. Paged track list filtered by releaseId (unauth) — expect 200, tracks for that release
# exercises: GET api/track/page?releaseId=
GET {{host}}/api/track/page?releaseId={{releaseId}}
Accept: application/json
###
### 2c. Paged track list filtered by album title (unauth) — expect 200
# exercises: GET api/track/page?album=
GET {{host}}/api/track/page?album=My+Album+Name
Accept: application/json
###
### 2d. Paged track list filtered by genre (unauth) — expect 200
# exercises: GET api/track/page?genre=
GET {{host}}/api/track/page?genre=Electronic
Accept: application/json
###
### 2e. Single track metadata by SQL id (ApiKey) — expect 200 TrackDto
# exercises: GET api/track/meta/{id:long}
GET {{host}}/api/track/meta/{{trackId}}
ApiKey: {{apiKey}}
Accept: application/json
###
### 2f. Track audio stream — full file (unauth) — expect 200 with audio bytes
# exercises: GET api/track/{trackId} → streams WAV from FileDatabase vault
GET {{host}}/api/track/{{entryKey}}
Accept: audio/wav
###
### 2g. Track audio stream — Range request (unauth) — expect 206 Partial Content
# exercises: GET api/track/{trackId} with Range header → 206 + byte slice
GET {{host}}/api/track/{{entryKey}}
Range: bytes=44-
Accept: audio/wav
###
### 2h. Albums list (unauth) — expect 200 List<ReleaseDto> with per-release track counts
# exercises: GET api/track/albums
GET {{host}}/api/track/albums
Accept: application/json
###
### 2i. Genres list (unauth) — expect 200 List<string> distinct genres
# exercises: GET api/track/genres
GET {{host}}/api/track/genres
Accept: application/json
###
### 2j. Random track (unauth) — expect 200 TrackDto (or 404 when library is empty)
# exercises: GET api/track/random
GET {{host}}/api/track/random
Accept: application/json
###
### 2k. Waveform profile for track (unauth) — expect 200 WaveformProfileDto or 404 if not computed
# exercises: GET api/track/{trackId}/waveform
GET {{host}}/api/track/{{entryKey}}/waveform
Accept: application/json
###
### 2l. Waveform status for all tracks (ApiKey) — expect 200 List<WaveformStatusDto>
# exercises: GET api/track/waveform-status (admin backfill view)
GET {{host}}/api/track/waveform-status
ApiKey: {{apiKey}}
Accept: application/json
###
###############################################################################
# 3. MEDIUM WRITE PATH
###############################################################################
### 3a. Upload a Cut track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Cut
# REPLACE ./path/to/test.wav with a real local .wav (or .mp3 / .flac) file path.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Cut
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Test Album
--boundary
Content-Disposition: form-data; name="genre"
Electronic
--boundary
Content-Disposition: form-data; name="releaseDate"
2025-01-01
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Cut
--boundary
Content-Disposition: form-data; name="releaseType"
Single
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3b. Upload a Session track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Session
# NOTE: Session releases are single-track. Use a unique album name to create a new release.
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Session
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Session Album 001
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3c. Upload a Mix track (ApiKey, multipart) — expect 200 TrackDto
# exercises: POST api/track/upload with medium=Mix
# NOTE: Mix releases are also single-track. Use a unique album name.
# REPLACE ./path/to/test.wav with a real local .wav/.mp3/.flac file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Smoke Test Mix
--boundary
Content-Disposition: form-data; name="artist"
Test Artist
--boundary
Content-Disposition: form-data; name="album"
Smoke Mix Album 001
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Mix
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 3d. Update track metadata — flip Medium to Session (ApiKey, JSON) — expect 200
# exercises: PUT api/track/meta/{id:long}
# Medium: null means "no change"; provide it to change the release medium.
PUT {{host}}/api/track/meta/{{trackId}}
ApiKey: {{apiKey}}
Content-Type: application/json
{
"TrackName": "Updated Track Name",
"Artist": "Updated Artist",
"Album": "Updated Album",
"Genre": "Electronic",
"ReleaseDate": "2025-06-01",
"ImagePath": null,
"ReleaseType": null,
"Medium": "Session",
"TrackNumber": 1
}
###
###############################################################################
# 4. WAVE 7 — SESSION CARDINALITY 409 SEQUENCE
#
# Session and Mix releases enforce a single-track maximum. The sequence below
# demonstrates the enforcement:
# Step 1: upload the FIRST track to a Session release → expect 200
# Step 2: upload a SECOND track to the SAME album+artist → expect 409 Conflict
#
# The 409 body will contain: "A Session release holds a single track; '<album>'
# already has one — edit the existing track or choose a different release."
#
# Cut-medium releases have no cardinality limit, so an identical sequence with
# medium=Cut would produce 200 on both requests.
###############################################################################
### 4a. [STEP 1] Upload first Session track — expect 200 TrackDto
# The album "Cardinality Test Session" does not yet exist; this creates it.
# REPLACE ./path/to/test.wav with a real local file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Cardinality Track 1
--boundary
Content-Disposition: form-data; name="artist"
Cardinality Artist
--boundary
Content-Disposition: form-data; name="album"
Cardinality Test Session
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
1
--boundary--
###
### 4b. [STEP 2] Upload second Session track to the SAME album+artist — expect 409 Conflict
# Same album "Cardinality Test Session", same artist "Cardinality Artist".
# UnifiedTrackService.UploadAsync pre-checks cardinality before the vault write.
# REPLACE ./path/to/test.wav with a real local file.
POST {{host}}/api/track/upload
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="audioFile"; filename="test.wav"
Content-Type: audio/wav
< ./path/to/test.wav
--boundary
Content-Disposition: form-data; name="trackName"
Cardinality Track 2
--boundary
Content-Disposition: form-data; name="artist"
Cardinality Artist
--boundary
Content-Disposition: form-data; name="album"
Cardinality Test Session
--boundary
Content-Disposition: form-data; name="createdByUserId"
1
--boundary
Content-Disposition: form-data; name="medium"
Session
--boundary
Content-Disposition: form-data; name="trackNumber"
2
--boundary--
###
###############################################################################
# 5. MIX WAVEFORM
###############################################################################
### 5a. Trigger waveform generation for a Mix release (ApiKey, no body) — expect 200 or 404
# exercises: POST api/release/{id:long}/mix/waveform
# 404 if the release is not a Mix, has no track, or the track has no audio stored.
POST {{host}}/api/release/{{releaseId}}/mix/waveform
ApiKey: {{apiKey}}
Content-Length: 0
###
### 5b. Fetch stored mix waveform (unauth) — expect 200 WaveformProfileDto or 404
# exercises: GET api/release/{id:long}/mix/waveform
# 404 when release is not a Mix, has no WaveformEntryKey, or datum not yet computed (run 5a first).
GET {{host}}/api/release/{{releaseId}}/mix/waveform
Accept: application/json
###
###############################################################################
# 6. SESSION HERO IMAGE
###############################################################################
### 6a. Upload hero image for a Session release (ApiKey, multipart) — expect 200 or 404
# exercises: POST api/release/{id:long}/session/hero-image
# 404 if release not found. 400 if no image file or unsupported content type.
# The release must be a Session (enforced in UnifiedReleaseService.SetHeroImageAsync).
# REPLACE ./path/to/hero.jpg with a real local JPEG or PNG file.
POST {{host}}/api/release/{{releaseId}}/session/hero-image
ApiKey: {{apiKey}}
Content-Type: multipart/form-data; boundary=boundary
--boundary
Content-Disposition: form-data; name="image"; filename="hero.jpg"
Content-Type: image/jpeg
< ./path/to/hero.jpg
--boundary--
###
@@ -1,12 +1,24 @@
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);
string? Description,
DateOnly? ReleaseDate,
string? ImagePath = null,
ReleaseType? ReleaseType = null,
ReleaseMedium? Medium = null,
int? TrackNumber = null);
+13
View File
@@ -0,0 +1,13 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
/// </summary>
public class UploadSettings
{
public string? StagingPath { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
/// </summary>
public sealed record UploadStagingDirectory(string Path);
}
+47 -2
View File
@@ -8,8 +8,10 @@ using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using NetBlocks.Utilities.Environment;
using System.Threading.RateLimiting;
// Required credential files — must exist before the app will start.
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
@@ -64,6 +66,22 @@ builder.Services
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
builder.Services.AddScoped<UnifiedTrackService>();
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
// release-resolution + counter-bump transaction.
builder.Services
.AddScoped<EventRepository>()
.AddScoped<EventManager>()
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
builder.Services
.AddScoped<ReleaseRepository>()
.AddScoped<ReleaseManager>()
.AddScoped<IReleaseService>(sp => sp.GetRequiredService<ReleaseManager>());
builder.Services.AddScoped<UnifiedReleaseService>();
// AuthBlocks: JWT Bearer auth, Identity, EF schema, role + admin seeding. This API host owns the
// AuthBlocks API surface (registration, migration/seed, endpoint mounting). The Manager keeps only
// web-side auth (AuthBlocksWeb) and never holds the signing secret, email creds, or admin creds.
@@ -85,10 +103,13 @@ builder.Services.AddAuthBlocks(options =>
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
options.AdminUserSettings = new AdminUserSettings
{
@@ -110,6 +131,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
options.KnownProxies.Clear();
});
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
// budget is generous for a real listening session and only bites on scripted spam.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("events", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
});
var app = builder.Build();
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
@@ -128,6 +168,11 @@ if (app.Environment.IsDevelopment())
app.UseCors("ContentApiPolicy");
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
// unaffected (no global limiter is set).
app.UseRateLimiter();
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
@@ -0,0 +1,176 @@
using DeepDrftContent;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftModels.Enums;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftAPI.Services;
/// <summary>
/// Host-internal orchestrator for the two release metadata write paths. Mirrors
/// <see cref="UnifiedTrackService"/>: it makes DeepDrftAPI the single authority over both the vault
/// (FileDatabase) and SQL satellite rows, so the controller stays a thin HTTP boundary and no caller
/// coordinates the two stores.
/// </summary>
public class UnifiedReleaseService
{
/// <summary>Error message returned when the Mix release has no linked track.</summary>
public const string MixHasNoTrackMessage = "Mix release has no track.";
/// <summary>Error message returned when the Mix track has no audio stored in the vault.</summary>
public const string MixTrackNoAudioMessage = "No audio stored for the Mix track.";
private readonly IReleaseService _releaseService;
private readonly FileDb _fileDatabase;
private readonly ImageProcessor _imageProcessor;
private readonly TrackContentService _trackContentService;
private readonly WaveformProfileService _waveformProfileService;
private readonly ILogger<UnifiedReleaseService> _logger;
public UnifiedReleaseService(
IReleaseService releaseService,
FileDb fileDatabase,
ImageProcessor imageProcessor,
TrackContentService trackContentService,
WaveformProfileService waveformProfileService,
ILogger<UnifiedReleaseService> logger)
{
_releaseService = releaseService;
_fileDatabase = fileDatabase;
_imageProcessor = imageProcessor;
_trackContentService = trackContentService;
_waveformProfileService = waveformProfileService;
_logger = logger;
}
/// <summary>
/// Process a hero image into the Images vault, then point the release's Session satellite at it.
/// The medium check lives in <see cref="IReleaseService.SetSessionHeroImageAsync"/>: if the release
/// is not a Session, the satellite is not written and the image is orphaned (logged, recoverable).
/// </summary>
public async Task<Result> SetHeroImageAsync(long releaseId, IFormFile imageFile, CancellationToken ct)
{
if (MimeTypeExtensions.GetExtension(imageFile.ContentType) == ".bin")
{
_logger.LogWarning(
"SetHeroImage rejected: unsupported content type '{ContentType}' for release {ReleaseId}",
imageFile.ContentType, releaseId);
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
}
byte[] buffer;
await using (var stream = imageFile.OpenReadStream())
using (var memory = new MemoryStream())
{
await stream.CopyToAsync(memory, ct);
buffer = memory.ToArray();
}
var imageBinary = _imageProcessor.Process(buffer, imageFile.ContentType);
if (imageBinary is null)
{
_logger.LogWarning("SetHeroImage: ImageProcessor rejected content type '{ContentType}'", imageFile.ContentType);
return Result.CreateFailResult($"Unsupported image content type: {imageFile.ContentType}");
}
var entryKey = Guid.NewGuid().ToString("N");
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
if (!stored)
{
_logger.LogError("SetHeroImage: vault write failed for release {ReleaseId}, entryKey={EntryKey}", releaseId, entryKey);
return Result.CreateFailResult("Failed to store hero image.");
}
var linked = await _releaseService.SetSessionHeroImageAsync(releaseId, entryKey, ct);
if (!linked.Success)
{
// Vault write succeeded, SQL link failed — image is orphaned in the Images vault under
// entryKey. Log loudly (include entryKey) so it is recoverable manually.
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"Hero image stored in vault but Session link failed. Orphaned entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
entryKey, releaseId, error);
return linked;
}
return Result.CreatePassResult();
}
/// <summary>
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
/// resolution (≈333 samples/sec derived from the track's duration; see
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
/// </summary>
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
{
var lookup = await _releaseService.GetByIdAsync(releaseId, ct);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("TriggerMixWaveform: release lookup failed for {ReleaseId}: {Error}", releaseId, error);
return Result.CreateFailResult("Failed to load release.");
}
if (lookup.Value is null)
return Result.CreateFailResult(ReleaseManager.ReleaseNotFoundMessage);
// Pre-check medium here (before fetching audio) to avoid expensive waveform compute on a
// non-Mix release. ReleaseManager.SetMixWaveformAsync enforces this too, so the double-check
// is intentional — the orchestrator's guard is the cheap early-exit.
if (lookup.Value.Medium != ReleaseMedium.Mix)
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
var keysResult = await _releaseService.GetTrackEntryKeysAsync(releaseId, ct);
if (!keysResult.Success || keysResult.Value is null)
{
var error = keysResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("TriggerMixWaveform: entry-key lookup failed for release {ReleaseId}: {Error}", releaseId, error);
return Result.CreateFailResult("Failed to load release tracks.");
}
var entryKey = keysResult.Value.FirstOrDefault();
if (entryKey is null)
{
_logger.LogWarning("TriggerMixWaveform: no track on Mix release {ReleaseId}", releaseId);
return Result.CreateFailResult(MixHasNoTrackMessage);
}
var audio = await _trackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
{
_logger.LogWarning("TriggerMixWaveform: no audio in vault for {EntryKey} (release {ReleaseId})", entryKey, releaseId);
return Result.CreateFailResult(MixTrackNoAudioMessage);
}
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
// high-res datum every track now carries (phase-12 §5).
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
audio.Buffer, entryKey, audio.Duration);
if (!computed)
{
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
return Result.CreateFailResult("Failed to compute the Mix waveform.");
}
var linked = await _releaseService.SetMixWaveformAsync(releaseId, entryKey, ct);
if (!linked.Success)
{
var error = linked.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"Mix waveform stored in vault but Mix link failed. Entry: {EntryKey}. Release: {ReleaseId}. Error: {Error}",
entryKey, releaseId, error);
return linked;
}
return Result.CreatePassResult();
}
}
+350 -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;
@@ -15,27 +17,49 @@ namespace DeepDrftAPI.Services;
public class UnifiedTrackService
{
internal const string TrackNotFoundMessage = "Track not found.";
/// <summary>
/// Stable marker prefixed onto a cardinality-rejection message so the controller can map this
/// specific failure to 409 Conflict (a well-formed request that violates a domain rule),
/// distinct from the 400 (malformed) and 500 (processing) paths. The human-readable detail
/// follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
/// <summary>
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
/// detail follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
private readonly WaveformProfileService _waveformProfileService;
private readonly 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,
@@ -43,12 +67,78 @@ public class UnifiedTrackService
string artist,
string? album,
string? genre,
string? description,
DateOnly? releaseDate,
long createdByUserId,
string? originalFileName,
ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber,
long? releaseId,
CancellationToken ct)
{
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
tempFilePath, trackName, artist, album, genre, releaseDate);
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
// orphans audio. Two paths:
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
// duplicate and is blocked (409).
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
// to the release row 1 just created. No (title, artist) lookup — the release id is
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
// future bounded medium is covered by the same line).
ResolvedRelease? resolved = null;
if (!string.IsNullOrWhiteSpace(album))
{
if (releaseId is { } attachId)
{
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!attachPeek.Success)
{
var error = attachPeek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
// The attach target must be the same release the natural key resolves to — a guard against
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
if (attachPeek.Value is not { } target || target.Id != attachId)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
"Start the upload again.");
}
var cardinalityCheck = CheckCardinality(target);
if (cardinalityCheck is { } violation)
return ResultContainer<TrackDto>.CreateFailResult(violation);
resolved = new ResolvedRelease(target.Id);
}
else
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
// edits or appends to an existing release.
if (peek.Value is { } existing)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
// resolved stays null → FindOrCreateRelease below creates the release.
}
}
var unpersisted = await _contentTrackContentService.AddTrackAsync(
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
if (unpersisted is null)
{
@@ -56,9 +146,63 @@ 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/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? resolvedReleaseId = resolved?.Id;
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
{
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
// mints the release. (The attach path already resolved the id from the pre-check above and
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
var releaseData = new ReleaseDto
{
Title = album,
Artist = artist,
Genre = genre,
Description = description,
ReleaseDate = releaseDate,
ReleaseType = releaseType,
Medium = medium,
CreatedByUserId = createdByUserId,
};
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
// rather than silently attaching, keeping the DB unique index as the final safety net.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success)
{
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}");
}
var (resolvedRelease, wasCreated) = releaseResult.Value;
if (!wasCreated)
{
// The winning concurrent upload created this release between our peek and our insert.
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
resolvedReleaseId = resolvedRelease.Id;
}
var trackDto = TrackConverter.Convert(unpersisted);
trackDto.ReleaseId = resolvedReleaseId;
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 +214,173 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
// frontend, so a failure here is logged and swallowed — never fails the upload.
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
return saveResult;
}
// The release a track resolved onto before the vault write. A null Id is the create path (mint
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
private readonly record struct ResolvedRelease(long Id);
// The cardinality guard shared by the attach path and (historically) the create path: a release
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
// message, or null when the add is within limits. The create path never trips this (a brand-new
// release has zero tracks and admits its first), so only the attach path calls it today.
private static string? CheckCardinality(ReleaseDto release)
{
var cardinality = MediumRules.CardinalityOf(release.Medium);
if (release.TrackCount + 1 > cardinality.Max)
{
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
}
return null;
}
/// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
/// new duration to SQL. Track id, EntryKey, release membership, track number, and all other
/// metadata are preserved. The waveform regen is best-effort (a missing datum renders as a flat
/// seekbar / blank visualizer downstream), so a datum failure is logged and swallowed rather than
/// failing the replace. The duration write is not best-effort — a failure is surfaced so derived
/// aggregates (e.g. MixRuntimeSeconds) do not silently go stale. No release-cardinality cascade
/// applies: the track count is unchanged, so the single-track-Mix case stays intact.
/// </summary>
public async Task<Result> ReplaceAudioAsync(long trackId, string tempFilePath, CancellationToken ct)
{
var lookup = await _sqlTrackService.GetById(trackId);
if (!lookup.Success)
{
var error = lookup.Messages.FirstOrDefault()?.Message ?? "unknown error";
_logger.LogError("ReplaceAudioAsync: GetById failed for track {TrackId}: {Error}", trackId, error);
return Result.CreateFailResult("Failed to load track.");
}
if (lookup.Value is null)
{
return Result.CreateFailResult(TrackNotFoundMessage);
}
var entryKey = lookup.Value.EntryKey;
var newAudio = await _contentTrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath);
if (newAudio is null)
{
_logger.LogWarning("ReplaceAudioAsync: content swap returned null for track {TrackId} ({EntryKey})", trackId, entryKey);
return Result.CreateFailResult("Failed to process and store the replacement audio.");
}
// The old waveform no longer matches the new bytes. Regenerate both datums in place; keyed
// by the same EntryKey, the re-run overwrites the stale data (proven re-runnable). The
// freshly stored buffer is the authoritative source — no re-read of the vault needed.
try
{
await _waveformProfileService.ComputeAndStoreAsync(newAudio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(newAudio.Buffer, entryKey, newAudio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey);
}
// Write the new duration to SQL. The vault bytes are already swapped, so this is the
// authoritative metadata update for the replace. A failure here is surfaced (unlike the
// best-effort waveform regen above) because a stale DurationSeconds silently corrupts
// derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint).
var durationWrite = await _sqlTrackService.SetDuration(trackId, newAudio.Duration, ct);
if (!durationWrite.Success)
{
var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
"ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}",
trackId, entryKey, error);
return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
}
return Result.CreatePassResult();
}
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
{
try
{
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
if (audio is null)
{
_logger.LogWarning(
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
entryKey);
return;
}
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
}
}
/// <summary>
/// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
/// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
/// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
/// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
/// failure is logged and skipped, never aborting the batch.
/// </summary>
public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
{
var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
if (!missing.Success || missing.Value is null)
{
var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
}
var updated = 0;
var skipped = 0;
foreach (var track in missing.Value)
{
ct.ThrowIfCancellationRequested();
var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
if (audio is null)
{
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
track.EntryKey, track.Id);
skipped++;
continue;
}
var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
if (!write.Success)
{
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
skipped++;
continue;
}
updated++;
}
_logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
}
/// <summary>
/// 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 +402,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 +412,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 +432,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);
}
}
}
+64 -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,12 +42,46 @@ 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();
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
return db;
});
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
// on-disk copies of an upload off /tmp onto the data disk:
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
// Default location is a "staging" subdirectory beside the vaults; override with
// Upload:StagingPath in appsettings.json.
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
Directory.CreateDirectory(stagingPath);
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
// absent, so set it (and create the dir) before any request is served.
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
return Task.CompletedTask;
}
/// <summary>
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
/// </summary>
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
{
var path = string.IsNullOrWhiteSpace(configuredPath)
? Path.Combine(vaultPath, "staging")
: configuredPath;
return Path.GetFullPath(path);
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Tracks))
@@ -45,5 +89,23 @@ 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);
}
}
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
{
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
}
}
}
}
+3
View File
@@ -7,6 +7,9 @@
}
},
"AllowedHosts": "*",
"Upload": {
"StagingPath": ""
},
"CorsSettings": {
"AllowedOrigins": [
"https://localhost:12778",
@@ -8,7 +8,9 @@
},
"Email": {
"Host": "smtp.your-provider.com",
"Token": "your-email-token-here"
"Token": "your-email-token-here",
"From": "noreply@yourdomain.com",
"TestInbox": "<sandbox-id>"
},
"Admin": {
"UserName": "admin",
-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);
}
}
+19 -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,30 @@ 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.
- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute.
- `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12.
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 +122,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"`, `VaultConstants.Images = "images"`, and `VaultConstants.TrackWaveforms = "track-waveforms"` — the vault names in production use. `TrackWaveforms` holds the per-track high-res waveform datum keyed by `TrackEntity.EntryKey` (Phase 12; renamed from the former `mix-waveforms`, which was Mix-only). New vault names go here when adding new vault types.
## 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,23 @@ 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";
/// <summary>
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
/// </summary>
public const string TrackWaveforms = "track-waveforms";
}
+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,196 @@
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, applies a ~15 ms envelope-follower smoothing
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
/// </summary>
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
{
/// <summary>
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
/// filter so the smoothing introduces no time lag.
/// </summary>
public const double SmoothingTimeConstantSeconds = 0.005;
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]++;
}
for (var i = 0; i < bucketCount; i++)
{
if (counts[i] > 0)
{
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
}
}
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
var totalSeconds = (double)frameCount / sampleRate;
var bucketSeconds = totalSeconds / bucketCount;
SmoothEnvelope(result, bucketSeconds);
var peak = 0.0;
for (var i = 0; i < bucketCount; i++)
{
if (result[i] > peak)
{
peak = result[i];
}
}
if (peak <= 0)
{
// Silence — return all zeros (Array is already zero-initialized).
Array.Clear(result);
return result;
}
for (var i = 0; i < bucketCount; i++)
{
result[i] /= peak;
}
return result;
}
/// <summary>
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
/// with the audio (no rightward time shift). The coefficient <c>a = exp(bucketSeconds / τ)</c>
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
/// each bucket blends <c>(1 a)</c> of itself with <c>a</c> of the running envelope. A near-zero
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
/// </summary>
private static void SmoothEnvelope(double[] data, double bucketSeconds)
{
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
{
return;
}
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
// Forward pass.
var env = data[0];
for (var i = 0; i < data.Length; i++)
{
env = a * env + (1 - a) * data[i];
data[i] = env;
}
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
env = data[^1];
for (var i = data.Length - 1; i >= 0; i--)
{
env = a * env + (1 - a) * data[i];
data[i] = env;
}
}
/// <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,154 @@
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"/> in <paramref name="vaultName"/> (defaults to
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
/// does not itself decide the count. Returns false (and logs) on any
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
/// </summary>
public async Task<bool> ComputeAndStoreAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
int? bucketCount = null,
string? vaultName = null)
{
var effectiveBucketCount = bucketCount ?? _options.BucketCount;
var effectiveVaultName = vaultName ?? VaultConstants.WaveformProfiles;
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,
effectiveBucketCount);
var quantized = Quantize(profile);
await EnsureVaultAsync(effectiveVaultName);
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
var stored = await _fileDatabase.RegisterResourceAsync(effectiveVaultName, 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>
/// Computes a track's high-resolution loudness datum and stores it in the
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
/// datum captures at a constant time resolution regardless of track length. This is the single home
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
/// Returns false (logged) on any failure, per the content-agnostic contract above.
/// </summary>
public Task<bool> ComputeAndStoreHighResAsync(
ReadOnlyMemory<byte> wavBytes,
string entryKey,
double durationSeconds)
{
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
}
/// <summary>
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
/// is stored (existing tracks predate profiling, and computation may have failed). Each byte is
/// a peak-normalized loudness value in [0, 255].
/// </summary>
public async Task<byte[]?> GetProfileAsync(string entryKey, string? vaultName = null)
{
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
vaultName ?? 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(string vaultName)
{
if (!_fileDatabase.HasVault(vaultName))
{
await _fileDatabase.CreateVaultAsync(vaultName, MediaVaultType.Media);
}
}
}
@@ -0,0 +1,53 @@
namespace DeepDrftContent.Processors;
/// <summary>
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
///
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
/// length — the direct expression of "high enough resolution regardless of content length."
///
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
/// </summary>
public static class WaveformResolution
{
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
public const int SamplesPerSecond = 333;
/// <summary>
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
/// </summary>
public const int MaxBucketCount = 2_000_000;
/// <summary>
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
/// </summary>
public const int MinBucketCount = 2048;
/// <summary>
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
/// </summary>
public static int BucketCountForDuration(double durationSeconds)
{
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
return MinBucketCount;
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
if (raw >= MaxBucketCount)
return MaxBucketCount;
var buckets = (int)raw;
return buckets < MinBucketCount ? MinBucketCount : buckets;
}
}
+115 -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,22 +67,116 @@ 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,
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
// need not touch the vault. Same value the high-res waveform compute reads downstream.
DurationSeconds = audioBinary.Duration
};
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>
/// Swaps the audio bytes for an existing track in place: processes a new audio file and
/// re-registers it under the SAME <paramref name="entryKey"/> in the tracks vault. The track's
/// vault key — and therefore its SQL link, release membership, position, and metadata — is
/// untouched; only the binary changes. The new audio is written first; only on confirmed success
/// is a stale old backing file cleaned up. A cross-format replacement (e.g. .wav → .flac) leaves
/// the old file on disk under its former filename once the index is updated; the post-success
/// cleanup removes it. For a same-extension overwrite the register alone suffices — the file is
/// written in place. If the register fails the original audio is left intact and null is returned,
/// so the track remains playable. Returns the freshly stored <see cref="AudioBinary"/> on success
/// (so the caller can regenerate waveform data from the same bytes) — matching the FileDatabase
/// swallow-and-return-null contract.
/// </summary>
public async Task<AudioBinary?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath)
{
try
{
// Capture the old extension before touching the vault. After register the index
// will point to the new extension, so we need the old value now to detect a
// cross-format swap and clean up the stale file post-success.
var existing = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, entryKey);
var oldExtension = existing?.Extension;
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
if (audioBinary == null)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
return null;
}
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
{
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
}
// Register the new audio. This upserts the index entry (new extension recorded) and
// writes the new file to disk. If this fails the original entry and file are untouched.
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary);
if (!success)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved");
return null;
}
// Post-success stale-file cleanup for cross-format swaps. The register wrote the new
// file (e.g. .flac) and updated the index to the new extension, but the old backing
// file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the
// old path — RemoveResourceAsync would now resolve to the new extension and delete the
// wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a
// playback issue (the index no longer references it).
if (oldExtension != null && oldExtension != audioBinary.Extension)
{
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
if (vault != null)
{
var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}");
try
{
if (File.Exists(staleFilePath))
File.Delete(staleFilePath);
}
catch (Exception ex)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk");
}
}
}
return audioBinary;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}");
return null;
}
}
+22 -7
View File
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
DeepDrftData/
├── Data/
│ ├── DeepDrftContext.cs # EF DbContext
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
│ └── Configurations/
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
@@ -32,7 +32,7 @@ DeepDrftData/
## EF DbContext and configuration
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
`TrackConfiguration` uses EF fluent API:
- Table name: `track` (singular)
@@ -42,6 +42,7 @@ DeepDrftData/
- `Album`, `Genre`: optional, max 200 / 100
- `ReleaseDate`: optional `DateOnly`
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future)
- `DurationSeconds`: optional `double?` (nullable; populated at upload from vault audio; backfillable via `POST api/track/duration/backfill`; used for aggregate mix-runtime queries). Column: `duration_seconds`. Migration: `20260618155002_AddTrackDuration`.
## Service → Repository → DbContext shape
@@ -49,6 +50,20 @@ DeepDrftData/
- **Repository** (`TrackRepository`): Internal data access. Queries the DbContext. Throws on error (service catches).
- **DbContext** (`DeepDrftContext`): EF Core. Directly accessed by repository, never by service (pattern isolation).
Notable repository / service methods beyond the standard CRUD:
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity``EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: `CountDistinctListenersAsync()` (site-wide), `CountDistinctListenersForTrackAsync(trackEntryKey)`, `CountDistinctListenersForReleaseAsync(releaseId)` — each excludes null `anon_id` rows.
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)` return NetBlocks `Result`. Wave 16.3 added three distinct-count members returning `ResultContainer<int>`: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated). The `anon_id` columns and covering indexes on `play_event`/`share_event` are part of this migration — no additional migration was needed for 16.3.
Example:
```csharp
@@ -117,10 +132,10 @@ Run from the solution root:
```bash
# Add a migration
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
# Apply to database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
```
The design-time factory means you can also run `dotnet ef ... --project DeepDrftData` standalone for local development (it doesn't need the startup project).
@@ -132,9 +147,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
## Connection string
- **DeepDrftAPI**: `environment/connections.json``ConnectionStrings:DefaultConnection`
- Points at the same database (PostgreSQL in production, SQLite for local development).
- Always PostgreSQL (Npgsql) — both production and local development.
The design-time factory hard-codes the local path for `dotnet ef` commands.
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
## Service registration
@@ -142,7 +157,7 @@ In `DeepDrftAPI/Program.cs`:
```csharp
services.AddDbContext<DeepDrftContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>();
services.AddScoped<TrackManager>();
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class MixMetadataConfiguration : BaseEntityConfiguration<MixMetadata>
{
public override void Configure(EntityTypeBuilder<MixMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("mix_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.WaveformEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("waveform_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.MixMetadata)
.HasForeignKey<MixMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_mix_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_mix_metadata_is_deleted");
}
}
@@ -0,0 +1,47 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the <c>play_counter</c> rollup (Phase 16 §4.1 / D6). One row per track, unique
/// on track_id so the incremental-on-write bump is an upsert against a single row. <c>TotalPlays</c> is
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
/// </summary>
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
{
public void Configure(EntityTypeBuilder<PlayCounter> builder)
{
builder.ToTable("play_counter");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TrackId)
.IsRequired()
.HasColumnName("track_id");
builder.Property(e => e.PartialCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("partial_count");
builder.Property(e => e.SampledCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
builder.Property(e => e.CompleteCount)
.IsRequired()
.HasDefaultValue(0L)
.HasColumnName("complete_count");
// Derived headline figure — never a column.
builder.Ignore(e => e.TotalPlays);
builder.HasIndex(e => e.TrackId)
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
}
}
@@ -0,0 +1,49 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
/// <c>BaseEntity</c> — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
/// query) so the aggregation paths stay cheap as the log grows.
/// </summary>
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
{
public void Configure(EntityTypeBuilder<PlayEvent> builder)
{
builder.ToTable("play_event");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TrackEntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("track_entry_key");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.Bucket)
.IsRequired()
.HasConversion<string>() // Store the readable bucket name, mirroring ReleaseMedium.
.HasMaxLength(20)
.HasColumnName("bucket");
// Reserved nullable token (wave 16.3). Same width as a stringified GUID.
builder.Property(e => e.AnonId)
.HasMaxLength(64)
.HasColumnName("anon_id");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
}
}
@@ -0,0 +1,106 @@
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");
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
// resolves to one row by its public key.
builder.Property(e => e.EntryKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("entry_key");
builder.HasIndex(e => e.EntryKey)
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
builder.Property(e => e.Title)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("title");
builder.Property(e => e.Artist)
.IsRequired()
.HasMaxLength(200)
.HasColumnName("artist");
builder.Property(e => e.Genre)
.HasMaxLength(100)
.HasColumnName("genre");
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
builder.Property(e => e.Description)
.HasMaxLength(4000)
.HasColumnName("description");
builder.Property(e => e.ReleaseDate)
.HasColumnName("release_date");
builder.Property(e => e.ImagePath)
.HasMaxLength(500)
.HasColumnName("image_path");
// ReleaseType is meaningful ONLY when Medium == Cut. It is the Cut medium's discriminator
// data and lives on the base table by deliberate, named exception:
// A CutMetadata satellite (mirroring SessionMetadata/MixMetadata) was considered and
// rejected. ReleaseType is read on every card of the /cuts browse — the highest-traffic
// read in the system. Moving it to a satellite would put a join on that hot path. So it
// stays here. Future media MUST NOT copy this pattern: the default is a satellite metadata
// table; this is the one allowed exception, justified solely by the /cuts read volume.
//
// The "ReleaseType only for Cut" invariant is advisory — enforced at the service layer and
// surfaced via the nullable ReleaseDto.ReleaseType (nulled for non-Cut at the converter).
// It is NOT a DB check constraint by choice, not necessity: EF supports HasCheckConstraint,
// but the invariant is advisory and we keep the schema free of it.
builder.Property(e => e.ReleaseType)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
.HasMaxLength(20)
.HasColumnName("release_type")
.HasDefaultValue(ReleaseType.Single);
builder.Property(e => e.Medium)
.IsRequired()
.HasConversion<string>() // Store as readable string, not int ordinal
.HasMaxLength(20)
.HasColumnName("medium")
.HasDefaultValue(ReleaseMedium.Cut); // Existing rows migrate to Cut with no data migration.
builder.Property(e => e.CreatedByUserId)
.HasColumnName("created_by_user_id");
// 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 UniqueViolation and re-queries for the winning row.
// Partial filter excludes soft-deleted rows so re-uploading a deleted release does not
// hit a uniqueness conflict when FindOrCreateRelease creates a fresh row.
builder.HasIndex(e => new { e.Title, e.Artist })
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
}
}
@@ -0,0 +1,48 @@
using Data.Data.Configurations;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
public class SessionMetadataConfiguration : BaseEntityConfiguration<SessionMetadata>
{
public override void Configure(EntityTypeBuilder<SessionMetadata> builder)
{
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
base.Configure(builder);
builder.ToTable("session_metadata");
// Map the base audit columns to the snake_case naming the rest of the schema uses.
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
builder.Property(e => e.HeroImageEntryKey)
.IsRequired()
.HasMaxLength(500) // Consistent with ImagePath on ReleaseEntity; entry keys can carry GUIDs.
.HasColumnName("hero_image_entry_key");
// 1:1 to the parent release. The unique FK index is the DB-level enforcement of the
// one-satellite-per-release cardinality. Cascade on delete: removing the release removes its
// medium satellite (unlike Track's SetNull — a satellite has no meaning without its release).
builder.HasOne(e => e.Release)
.WithOne(r => r.SessionMetadata)
.HasForeignKey<SessionMetadata>(e => e.ReleaseId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(e => e.ReleaseId)
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
// "IX_session_metadata_is_deleted" regardless of auto-naming conventions.
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_session_metadata_is_deleted");
}
}
@@ -0,0 +1,48 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
/// entity. Indexed on the target key so per-target share tallies stay cheap.
/// </summary>
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
{
public void Configure(EntityTypeBuilder<ShareEvent> builder)
{
builder.ToTable("share_event");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TargetType)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("target_type");
builder.Property(e => e.TargetKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("target_key");
builder.Property(e => e.Channel)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("channel");
builder.Property(e => e.AnonId)
.HasMaxLength(64)
.HasColumnName("anon_id");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
}
}
@@ -30,28 +30,28 @@ 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);
// Nullable: existing rows carry NULL until the one-time duration backfill populates them.
builder.Property(e => e.DurationSeconds)
.HasColumnName("duration_seconds");
builder.Property(e => e.ReleaseId)
.HasColumnName("release_id");
// 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
+15
View File
@@ -11,11 +11,26 @@ public class DeepDrftContext : DbContext
}
public DbSet<TrackEntity> Tracks { get; set; }
public DbSet<ReleaseEntity> Releases { get; set; }
public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { get; set; }
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
// FileDatabase vault is not involved.
public DbSet<PlayEvent> PlayEvents { get; set; }
public DbSet<ShareEvent> ShareEvents { get; set; }
public DbSet<PlayCounter> PlayCounters { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new TrackConfiguration());
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
}
}
+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);
+3 -2
View File
@@ -18,8 +18,9 @@
</PackageReference>
<!-- Npgsql 10.0.1 requires Microsoft.EntityFrameworkCore >= 10.0.4; keep in sync -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.30" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.30" />
<PackageReference Include="Cerebellum.NetBlocks" Version="10.3.32" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data" Version="10.3.35" />
<PackageReference Include="Cerebellum.BlazorBlocks.Data.Postgres" Version="10.3.35" />
</ItemGroup>
<ItemGroup>
+114
View File
@@ -0,0 +1,114 @@
using DeepDrftData.Repositories;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// <see cref="IEventService"/> implementation over <see cref="EventRepository"/>. The layer boundary
/// matches the rest of DeepDrftData: the repository owns the EF constructs and the write transaction;
/// this service catches at the boundary and returns a NetBlocks <see cref="Result"/>. Telemetry is
/// best-effort by design (§2.2) — a failed write is logged and surfaced as a fail result, never thrown
/// at the caller, so a telemetry hiccup can never reach a listener.
/// </summary>
public class EventManager : IEventService
{
private readonly EventRepository _repository;
private readonly ILogger<EventManager> _logger;
public EventManager(EventRepository repository, ILogger<EventManager> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Result> RecordPlay(
string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default)
{
try
{
await _repository.RecordPlayAsync(trackEntryKey, bucket, anonId, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
_logger.LogError(e, "Failed to record play event for track {TrackEntryKey}", trackEntryKey);
return Result.CreateFailResult(e.Message);
}
}
public async Task<Result> RecordShare(
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null,
CancellationToken cancellationToken = default)
{
try
{
await _repository.RecordShareAsync(targetType, targetKey, channel, anonId, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
_logger.LogError(e, "Failed to record share event for {TargetType} {TargetKey}", targetType, targetKey);
return Result.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountTotalPlaysAsync(cancellationToken);
return ResultContainer<long>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count total plays");
return ResultContainer<long>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersAsync(cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners");
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCountForTrack(
string trackEntryKey, CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey);
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> GetDistinctListenerCountForRelease(
long releaseId, CancellationToken cancellationToken = default)
{
try
{
var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken);
return ResultContainer<int>.CreatePassResult(count);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId);
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
using DeepDrftModels.Enums;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side anonymous telemetry service (Phase 16). Records play and share events to the append-only
/// logs and maintains the incremental play-counter rollup. The release dimension on a play is resolved
/// server-side from the track key (§2.3 / D4) — callers pass only what the client cheaply knows.
/// Returns NetBlocks <see cref="Result"/> at the boundary; the controller maps that to 202/4xx/5xx.
/// </summary>
public interface IEventService
{
/// <summary>
/// Record one play: append a <c>play_event</c> row (release resolved from the track key) and bump
/// the track's <c>play_counter</c> in the same transaction. A play of an unknown/removed track key
/// still logs (with a null release and no counter bump) rather than failing.
/// </summary>
Task<Result> RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
/// <summary>
/// Site-wide total play count (Phase 16 §5 — all-time): the sum of every <c>play_counter</c> row's
/// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary
/// figure; the controller composes it onto <c>HomeStatsDto</c> alongside the track-domain figures.
/// </summary>
Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default);
/// <summary>
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c>
/// values across all play events. Null tokens are excluded (not a known listener). The capability for
/// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3.
/// </summary>
Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default);
/// <summary>Distinct listeners who played the given track (by vault entry key). Null tokens excluded.</summary>
Task<ResultContainer<int>> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default);
/// <summary>
/// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in
/// the release counts once. Null tokens excluded.
/// </summary>
Task<ResultContainer<int>> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default);
}
+34
View File
@@ -0,0 +1,34 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side release service. Repository outputs entities; this service outputs DTOs via TrackConverter.
/// Backs the medium-aware release read endpoints (paged list + by-id detail) and the two metadata
/// write paths (Session hero image, Mix waveform). The entity never escapes the service layer.
/// </summary>
public interface IReleaseService
{
/// <summary>Paginated releases, optionally narrowed by medium and a free-text/genre filter. The matching medium's metadata satellite is included in the result. Omit medium for all releases; omit filter for no search/genre narrowing.</summary>
Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default);
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
/// <summary>Find-or-create the Session satellite and set its hero image entry key. Fails when the release is not a Session.</summary>
Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default);
/// <summary>Find-or-create the Mix satellite and set its waveform entry key. Fails when the release is not a Mix.</summary>
Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default);
}
+72 -1
View File
@@ -12,9 +12,80 @@ 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>
/// Aggregate figures behind the public home hero stat row: Cut track count + per-ReleaseType Cut
/// release breakdown, Mix release count, and total Mix runtime in seconds. One read for all three cards.
/// </summary>
Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default);
/// <summary>
/// Non-deleted tracks whose SQL duration is still null — the work list for the one-time duration
/// backfill. The backfill reads each track's stored duration from the vault and writes it via
/// <see cref="UpdateDuration"/>.
/// </summary>
Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default);
/// <summary>
/// Set the SQL duration for one track. Idempotent: a track whose duration is already set is left
/// untouched. Backs the duration backfill. Returns the number of rows updated (0 or 1).
/// </summary>
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Unconditionally overwrite the SQL duration for one track. Unlike <see cref="UpdateDuration"/>,
/// this carries no null guard — it is for the replace-audio path where the track already has a
/// non-null duration that must be overwritten with the new audio's value. Returns a fail Result
/// when zero rows are affected (track removed between lookup and write).
/// </summary>
Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
/// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
/// </summary>
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary>
/// Read-only peek for an existing release by its natural key, or null when none exists — a find
/// with no create side-effect. Backs the upload cardinality pre-check, which must read a release's
/// medium and live-track count before deciding whether to admit an upload, without creating a
/// release for an upload it may reject. The returned DTO carries TrackCount.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
string title, string artist, CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id);
/// <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");
}
}
}
@@ -0,0 +1,179 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260612102604_MakeReleaseTitleArtistUniquePartial")]
partial class MakeReleaseTitleArtistUniquePartial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class MakeReleaseTitleArtistUniquePartial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_title_artist",
table: "release");
migrationBuilder.CreateIndex(
name: "IX_release_title_artist",
table: "release",
columns: new[] { "title", "artist" },
unique: true,
filter: "\"is_deleted\" = false");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_title_artist",
table: "release");
migrationBuilder.CreateIndex(
name: "IX_release_title_artist",
table: "release",
columns: new[] { "title", "artist" },
unique: true);
}
}
}
@@ -0,0 +1,303 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260613013826_AddReleaseMedium")]
partial class AddReleaseMedium
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseMedium : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "medium",
table: "release",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Cut");
migrationBuilder.CreateTable(
name: "mix_metadata",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
waveform_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_mix_metadata", x => x.id);
table.ForeignKey(
name: "FK_mix_metadata_release_release_id",
column: x => x.release_id,
principalTable: "release",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "session_metadata",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
release_id = table.Column<long>(type: "bigint", nullable: false),
hero_image_entry_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_session_metadata", x => x.id);
table.ForeignKey(
name: "FK_session_metadata_release_release_id",
column: x => x.release_id,
principalTable: "release",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_mix_metadata_is_deleted",
table: "mix_metadata",
column: "is_deleted");
migrationBuilder.CreateIndex(
name: "IX_mix_metadata_release_id",
table: "mix_metadata",
column: "release_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_session_metadata_is_deleted",
table: "session_metadata",
column: "is_deleted");
migrationBuilder.CreateIndex(
name: "IX_session_metadata_release_id",
table: "session_metadata",
column: "release_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mix_metadata");
migrationBuilder.DropTable(
name: "session_metadata");
migrationBuilder.DropColumn(
name: "medium",
table: "release");
}
}
}
@@ -0,0 +1,308 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260616035252_AddReleaseDescription")]
partial class AddReleaseDescription
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseDescription : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "description",
table: "release",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "description",
table: "release");
}
}
}
@@ -0,0 +1,318 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260616210143_AddReleaseEntryKey")]
partial class AddReleaseEntryKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddReleaseEntryKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
// mark non-null. No DB default is set on the final column: new rows are app-populated by
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
migrationBuilder.AddColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: true);
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
migrationBuilder.Sql(
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
// 3) Now every row is populated and unique — enforce NOT NULL.
migrationBuilder.AlterColumn<string>(
name: "entry_key",
table: "release",
type: "character varying(100)",
maxLength: 100,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_release_entry_key",
table: "release",
column: "entry_key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_release_entry_key",
table: "release");
migrationBuilder.DropColumn(
name: "entry_key",
table: "release");
}
}
}
@@ -0,0 +1,322 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260618155002_AddTrackDuration")]
partial class AddTrackDuration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddTrackDuration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "duration_seconds",
table: "track",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "duration_seconds",
table: "track");
}
}
}
@@ -0,0 +1,457 @@
// <auto-generated />
using System;
using DeepDrftData.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
[DbContext(typeof(DeepDrftContext))]
[Migration("20260619155610_AddPlayShareTelemetry")]
partial class AddPlayShareTelemetry
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CompleteCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("complete_count");
b.Property<long>("PartialCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("partial_count");
b.Property<long>("SampledCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
b.Property<long>("TrackId")
.HasColumnType("bigint")
.HasColumnName("track_id");
b.HasKey("Id");
b.HasIndex("TrackId")
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
b.ToTable("play_counter", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("bucket");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackEntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("track_entry_key");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_play_event_anon_id");
b.HasIndex("ReleaseId")
.HasDatabaseName("IX_play_event_release_id");
b.HasIndex("TrackEntryKey")
.HasDatabaseName("IX_play_event_track_entry_key");
b.ToTable("play_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("artist");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("CreatedByUserId")
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<string>("Genre")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("genre");
b.Property<string>("ImagePath")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("image_path");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("TargetKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("target_key");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("target_type");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_share_event_anon_id");
b.HasIndex("TargetKey")
.HasDatabaseName("IX_share_event_target_key");
b.ToTable("share_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DeepDrftData.Migrations
{
/// <inheritdoc />
public partial class AddPlayShareTelemetry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "play_counter",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
track_id = table.Column<long>(type: "bigint", nullable: false),
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
complete_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L)
},
constraints: table =>
{
table.PrimaryKey("PK_play_counter", x => x.id);
});
migrationBuilder.CreateTable(
name: "play_event",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
release_id = table.Column<long>(type: "bigint", nullable: true),
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_play_event", x => x.id);
});
migrationBuilder.CreateTable(
name: "share_event",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_share_event", x => x.id);
});
migrationBuilder.CreateIndex(
name: "IX_play_counter_track_id",
table: "play_counter",
column: "track_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_play_event_anon_id",
table: "play_event",
column: "anon_id");
migrationBuilder.CreateIndex(
name: "IX_play_event_release_id",
table: "play_event",
column: "release_id");
migrationBuilder.CreateIndex(
name: "IX_play_event_track_entry_key",
table: "play_event",
column: "track_entry_key");
migrationBuilder.CreateIndex(
name: "IX_share_event_anon_id",
table: "share_event",
column: "anon_id");
migrationBuilder.CreateIndex(
name: "IX_share_event_target_key",
table: "share_event",
column: "target_key");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "play_counter");
migrationBuilder.DropTable(
name: "play_event");
migrationBuilder.DropTable(
name: "share_event");
}
}
}
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
@@ -31,10 +31,138 @@ namespace DeepDrftData.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Album")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("album");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("WaveformEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("waveform_entry_key");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_mix_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_mix_metadata_release_id");
b.ToTable("mix_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("CompleteCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("complete_count");
b.Property<long>("PartialCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("partial_count");
b.Property<long>("SampledCount")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(0L)
.HasColumnName("sampled_count");
b.Property<long>("TrackId")
.HasColumnType("bigint")
.HasColumnName("track_id");
b.HasKey("Id");
b.HasIndex("TrackId")
.IsUnique()
.HasDatabaseName("IX_play_counter_track_id");
b.ToTable("play_counter", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("bucket");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackEntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("track_entry_key");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_play_event_anon_id");
b.HasIndex("ReleaseId")
.HasDatabaseName("IX_play_event_release_id");
b.HasIndex("TrackEntryKey")
.HasDatabaseName("IX_play_event_track_entry_key");
b.ToTable("play_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Artist")
.IsRequired()
@@ -50,6 +178,11 @@ namespace DeepDrftData.Migrations
.HasColumnType("bigint")
.HasColumnName("created_by_user_id");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)")
.HasColumnName("description");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
@@ -72,16 +205,195 @@ namespace DeepDrftData.Migrations
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("Medium")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Cut")
.HasColumnName("medium");
b.Property<DateOnly?>("ReleaseDate")
.HasColumnType("date")
.HasColumnName("release_date");
b.Property<string>("ReleaseType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Single")
.HasColumnName("release_type");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("title");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("EntryKey")
.IsUnique()
.HasDatabaseName("IX_release_entry_key");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_release_is_deleted");
b.HasIndex("Title", "Artist")
.IsUnique()
.HasDatabaseName("IX_release_title_artist")
.HasFilter("\"is_deleted\" = false");
b.ToTable("release", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("HeroImageEntryKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("hero_image_entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<long>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_session_metadata_is_deleted");
b.HasIndex("ReleaseId")
.IsUnique()
.HasDatabaseName("IX_session_metadata_release_id");
b.ToTable("session_metadata", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("AnonId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("anon_id");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("TargetKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("target_key");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("target_type");
b.HasKey("Id");
b.HasIndex("AnonId")
.HasDatabaseName("IX_share_event_anon_id");
b.HasIndex("TargetKey")
.HasDatabaseName("IX_share_event_target_key");
b.ToTable("share_event", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
b.Property<string>("EntryKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("entry_key");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_deleted");
b.Property<string>("OriginalFileName")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("original_file_name");
b.Property<long?>("ReleaseId")
.HasColumnType("bigint")
.HasColumnName("release_id");
b.Property<string>("TrackName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("track_name");
b.Property<int>("TrackNumber")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1)
.HasColumnName("track_number");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -91,8 +403,51 @@ namespace DeepDrftData.Migrations
b.HasIndex("IsDeleted")
.HasDatabaseName("IX_track_is_deleted");
b.HasIndex("ReleaseId");
b.ToTable("track", (string)null);
});
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("MixMetadata")
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithOne("SessionMetadata")
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
.WithMany("Tracks")
.HasForeignKey("ReleaseId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Release");
});
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{
b.Navigation("MixMetadata");
b.Navigation("SessionMetadata");
b.Navigation("Tracks");
});
#pragma warning restore 612, 618
}
}
+163
View File
@@ -0,0 +1,163 @@
using System.Linq.Expressions;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using Models.Common;
using NetBlocks.Models;
namespace DeepDrftData;
/// <summary>
/// SQL-side release service implementing <see cref="IReleaseService"/>. Deliberately does NOT extend
/// <c>Manager&lt;&gt;</c>: that CRUD base does not fit this read-projection + satellite-write purpose.
/// The layer boundary holds — ReleaseRepository outputs entities, this manager outputs DTOs via
/// TrackConverter, the single authoritative conversion path.
/// </summary>
public class ReleaseManager : IReleaseService
{
// Distinguishes "release does not exist" from a real failure so the controller can map to 404.
public const string ReleaseNotFoundMessage = "Release not found.";
private readonly ReleaseRepository _repository;
private readonly ILogger<ReleaseManager> _logger;
public ReleaseManager(ReleaseRepository repository, ILogger<ReleaseManager> logger)
{
_repository = repository;
_logger = logger;
}
// Nulls sort to end via the coalescing sentinels, matching TrackManager's convention.
private static Expression<Func<ReleaseEntity, object>> GetOrderExpression(string? sortColumn)
=> sortColumn switch
{
"Title" => r => r.Title,
"Artist" => r => r.Artist,
"ReleaseDate" => r => (object)(r.ReleaseDate ?? DateOnly.MaxValue),
"Medium" => r => r.Medium,
_ => r => r.Id
};
public async Task<ResultContainer<PagedResult<ReleaseDto>>> GetPagedAsync(
int page, int pageSize, string? sortColumn, bool sortDescending,
ReleaseMedium? medium, ReleaseFilter? filter = null, CancellationToken cancellationToken = default)
{
try
{
var parameters = new PagingParameters<ReleaseEntity>
{
Page = page,
PageSize = pageSize,
OrderBy = GetOrderExpression(sortColumn),
IsDescending = sortDescending,
};
// Collapse an all-null filter to null so the repository skips the predicate block entirely.
var effectiveFilter = filter is { IsEmpty: false } ? filter : null;
var entityPage = await _repository.GetPagedByMediumAsync(parameters, medium, effectiveFilter, cancellationToken);
var releaseIds = entityPage.Items.Select(r => r.Id).ToList();
var counts = await _repository.GetTrackCountsByReleaseIdsAsync(releaseIds, cancellationToken);
var dtos = entityPage.Items
.Select(r =>
{
var dto = TrackConverter.Convert(r);
dto.TrackCount = counts.GetValueOrDefault(r.Id);
return dto;
});
var dtoPage = PagedResult<ReleaseDto>.From(entityPage, dtos);
return ResultContainer<PagedResult<ReleaseDto>>.CreatePassResult(dtoPage);
}
catch (Exception e)
{
return ResultContainer<PagedResult<ReleaseDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByIdWithMetadataAsync(id, cancellationToken);
// TrackConverter nulls the non-matching satellite. TrackCount is not loaded for the detail
// read (the Tracks collection isn't Include'd) and is not needed by detail consumers.
return ResultContainer<ReleaseDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
{
try
{
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
return ResultContainer<ReleaseDto?>.CreatePassResult(
entity is null ? null : TrackConverter.Convert(entity));
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
{
try
{
var keys = await _repository.GetTrackEntryKeysByReleaseIdAsync(releaseId, cancellationToken);
return ResultContainer<List<string>>.CreatePassResult(keys);
}
catch (Exception e)
{
return ResultContainer<List<string>>.CreateFailResult(e.Message);
}
}
public async Task<Result> SetSessionHeroImageAsync(long releaseId, string heroImageEntryKey, CancellationToken cancellationToken = default)
{
try
{
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
if (release is null)
return Result.CreateFailResult(ReleaseNotFoundMessage);
if (release.Medium != ReleaseMedium.Session)
return Result.CreateFailResult($"Release {releaseId} is not a Session medium.");
await _repository.SetHeroImageEntryKeyAsync(releaseId, heroImageEntryKey, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
return Result.CreateFailResult(e.Message);
}
}
public async Task<Result> SetMixWaveformAsync(long releaseId, string waveformEntryKey, CancellationToken cancellationToken = default)
{
try
{
var release = await _repository.GetByIdWithMetadataAsync(releaseId, cancellationToken);
if (release is null)
return Result.CreateFailResult(ReleaseNotFoundMessage);
if (release.Medium != ReleaseMedium.Mix)
return Result.CreateFailResult($"Release {releaseId} is not a Mix medium.");
await _repository.SetWaveformEntryKeyAsync(releaseId, waveformEntryKey, cancellationToken);
return Result.CreatePassResult();
}
catch (Exception e)
{
return Result.CreateFailResult(e.Message);
}
}
}
@@ -0,0 +1,175 @@
using DeepDrftData.Data;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
namespace DeepDrftData.Repositories;
/// <summary>
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
/// track→release at write time and stamps the release id on the row.
///
/// <para>
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
/// BlazorBlocks <c>Repository&lt;&gt;</c> base. It holds the same scoped <see cref="DeepDrftContext"/>
/// the rest of the SQL layer uses, never a service locator.
/// </para>
/// </summary>
public class EventRepository
{
private readonly DeepDrftContext _context;
public EventRepository(DeepDrftContext context)
{
_context = context;
}
/// <summary>
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
/// for a loose track); an unknown key records the event with a null release and no counter bump
/// (there is no track to roll up against). Returns true when the event was written.
/// </summary>
public async Task<bool> RecordPlayAsync(
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
{
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
// a since-removed track still logs (with no counter bump) rather than throwing.
var track = await _context.Tracks
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
.Select(t => new { t.Id, t.ReleaseId })
.FirstOrDefaultAsync(ct);
// The append and the counter bump must commit together — wrap them in one transaction so a
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
// already opened one.
var ownsTransaction = _context.Database.CurrentTransaction is null;
var transaction = ownsTransaction
? await _context.Database.BeginTransactionAsync(ct)
: null;
try
{
_context.PlayEvents.Add(new PlayEvent
{
TrackEntryKey = trackEntryKey,
ReleaseId = track?.ReleaseId,
Bucket = bucket,
AnonId = anonId,
CreatedAt = DateTime.UtcNow,
});
if (track is not null)
await BumpCounterAsync(track.Id, bucket, ct);
await _context.SaveChangesAsync(ct);
if (transaction is not null)
await transaction.CommitAsync(ct);
return true;
}
catch
{
if (transaction is not null)
await transaction.RollbackAsync(ct);
throw;
}
finally
{
if (transaction is not null)
await transaction.DisposeAsync();
}
}
/// <summary>
/// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16
/// §5). Sums the mapped columns directly rather than <see cref="PlayCounter.TotalPlays"/>, which is an
/// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home
/// card's expected reading until the telemetry migration is applied).
/// </summary>
public Task<long> CountTotalPlaysAsync(CancellationToken ct = default)
=> _context.PlayCounters
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct);
/// <summary>
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time
/// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage
/// was unavailable) are excluded — they are not a known listener and must not inflate the count. This
/// is the site-wide listener reach figure; the per-track / per-release overloads scope it.
/// </summary>
public Task<int> CountDistinctListenersAsync(CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>
/// Distinct listeners for one track, keyed by its vault entry key (the same key the play event
/// stamps). Null anon ids excluded. Per-track scope of <see cref="CountDistinctListenersAsync()"/>.
/// </summary>
public Task<int> CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>
/// Distinct listeners for one release, derived across the release's tracks (D4): the play event
/// stamps the resolved release id at write time, so a distinct count over <c>anon_id</c> filtered by
/// <c>release_id</c> is exactly "distinct listeners who played any track in this release." Null anon
/// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count
/// over the union, not a sum of per-track counts).
/// </summary>
public Task<int> CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default)
=> _context.PlayEvents
.Where(e => e.ReleaseId == releaseId && e.AnonId != null)
.Select(e => e.AnonId)
.Distinct()
.CountAsync(ct);
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
public async Task RecordShareAsync(
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
CancellationToken ct = default)
{
_context.ShareEvents.Add(new ShareEvent
{
TargetType = targetType,
TargetKey = targetKey,
Channel = channel,
AnonId = anonId,
CreatedAt = DateTime.UtcNow,
});
await _context.SaveChangesAsync(ct);
}
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
// it inside the same transaction as the event append.
//
// Race note: two concurrent first-plays of the same track can both reach this method, find no
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
// unique index is the integrity backstop.
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
{
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
if (counter is null)
{
counter = new PlayCounter { TrackId = trackId };
_context.PlayCounters.Add(counter);
}
switch (bucket)
{
case PlayBucket.Partial: counter.PartialCount++; break;
case PlayBucket.Sampled: counter.SampledCount++; break;
case PlayBucket.Complete: counter.CompleteCount++; break;
}
}
}
@@ -0,0 +1,172 @@
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
namespace DeepDrftData.Repositories;
/// <summary>
/// Medium-aware release queries. Deliberately does NOT extend <c>Repository&lt;DeepDrftContext, ReleaseEntity&gt;</c>:
/// that base is generic CRUD, while this repository's purpose is read-projection (paged, medium-filtered)
/// and satellite-row management (Session/Mix metadata find-or-create). Injects <see cref="DeepDrftContext"/>
/// directly so reads/writes stay in one unit of work.
/// </summary>
public class ReleaseRepository
{
private readonly DeepDrftContext _context;
private readonly ILogger<ReleaseRepository> _logger;
public ReleaseRepository(DeepDrftContext context, ILogger<ReleaseRepository> logger)
{
_context = context;
_logger = logger;
}
// Single location where the medium↔metadata correlation is determined on a list read: a satellite
// is loaded only when the caller's medium filter matches it. TrackConverter.Convert(ReleaseEntity)
// enforces the same rule at the DTO boundary (nulling non-matching satellites); this map ensures a
// non-matching satellite is never even queried. Cut (or no filter) loads no satellite on list reads.
private static IQueryable<ReleaseEntity> ApplyMediumInclude(IQueryable<ReleaseEntity> query, ReleaseMedium? medium)
=> medium switch
{
ReleaseMedium.Session => query.Include(r => r.SessionMetadata),
ReleaseMedium.Mix => query.Include(r => r.MixMetadata),
_ => query
};
// Paged release list, optionally narrowed by medium and a free-text/genre filter. The matching
// medium's satellite is Include'd; total count reflects every applied predicate (all before
// Skip/Take). The filter predicates mirror TrackRepository.GetPagedFilteredAsync so the release
// browse path searches and filters identically to the track path.
public async Task<PagedResult<ReleaseEntity>> GetPagedByMediumAsync(
PagingParameters<ReleaseEntity> paging,
ReleaseMedium? medium,
ReleaseFilter? filter,
CancellationToken ct)
{
IQueryable<ReleaseEntity> query = _context.Releases.Where(r => !r.IsDeleted);
if (medium.HasValue)
query = query.Where(r => r.Medium == medium.Value);
if (filter is not null)
{
if (!string.IsNullOrWhiteSpace(filter.SearchText))
{
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
// EF-translatable where ToLower().Contains() is not. Title/Artist are non-null columns
// on the release itself, so no navigation guard is needed (unlike the track path).
var pattern = $"%{filter.SearchText}%";
query = query.Where(r =>
EF.Functions.ILike(r.Title, pattern)
|| EF.Functions.ILike(r.Artist, pattern));
}
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(r => r.Genre == filter.Genre);
}
query = ApplyMediumInclude(query, medium);
var totalCount = await query.CountAsync(ct);
if (paging.OrderBy is not null)
query = paging.IsDescending ? query.OrderByDescending(paging.OrderBy) : query.OrderBy(paging.OrderBy);
var items = await query.Skip(paging.Skip).Take(paging.PageSize).ToListAsync(ct);
return new PagedResult<ReleaseEntity>
{
Items = items,
TotalCount = totalCount,
Page = paging.Page,
PageSize = paging.PageSize,
};
}
// Single release with both satellites Include'd: the medium is unknown until fetched, and both are
// 1:1 FK-indexed joins. TrackConverter nulls the non-matching satellite at the DTO boundary.
public async Task<ReleaseEntity?> GetByIdWithMetadataAsync(long id, CancellationToken ct)
=> await _context.Releases
.Where(r => r.Id == id && !r.IsDeleted)
.Include(r => r.SessionMetadata)
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
// public surface. The resolved entity still carries its int Id for internal joins (track page).
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
=> await _context.Releases
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
.Include(r => r.SessionMetadata)
.Include(r => r.MixMetadata)
.FirstOrDefaultAsync(ct);
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
IEnumerable<long> releaseIds,
CancellationToken ct)
{
var ids = releaseIds.ToList();
return await _context.Tracks
.Where(t => !t.IsDeleted && t.ReleaseId != null && ids.Contains(t.ReleaseId.Value))
.GroupBy(t => t.ReleaseId!.Value)
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
}
// Vault entry keys of the non-deleted tracks on a release, track-number ascending. Single-entry for
// Session/Mix (enforced at upload); may be multiple for Cut.
public async Task<List<string>> GetTrackEntryKeysByReleaseIdAsync(long releaseId, CancellationToken ct)
=> await _context.Tracks
.Where(t => !t.IsDeleted && t.ReleaseId == releaseId)
.OrderBy(t => t.TrackNumber)
.Select(t => t.EntryKey)
.ToListAsync(ct);
// Find-or-create the Session satellite for a release and set its hero-image entry key. The 1:1 FK
// makes (ReleaseId) the natural key; a repeat call updates the existing row in place.
public async Task SetHeroImageEntryKeyAsync(long releaseId, string heroImageEntryKey, CancellationToken ct)
{
var existing = await _context.SessionMetadata.FirstOrDefaultAsync(s => s.ReleaseId == releaseId, ct);
if (existing is not null)
{
existing.HeroImageEntryKey = heroImageEntryKey;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
return;
}
_context.SessionMetadata.Add(new SessionMetadata
{
ReleaseId = releaseId,
HeroImageEntryKey = heroImageEntryKey,
});
await _context.SaveChangesAsync(ct);
}
// Find-or-create the Mix satellite for a release and set its waveform entry key. Same 1:1 find-or-create
// pattern as SetHeroImageEntryKeyAsync.
public async Task SetWaveformEntryKeyAsync(long releaseId, string waveformEntryKey, CancellationToken ct)
{
var existing = await _context.MixMetadata.FirstOrDefaultAsync(m => m.ReleaseId == releaseId, ct);
if (existing is not null)
{
existing.WaveformEntryKey = waveformEntryKey;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(ct);
return;
}
_context.MixMetadata.Add(new MixMetadata
{
ReleaseId = releaseId,
WaveformEntryKey = waveformEntryKey,
});
await _context.SaveChangesAsync(ct);
}
}
+257 -6
View File
@@ -1,31 +1,282 @@
using Data.Data.Repositories;
using Data.Errors;
using DeepDrftData.Data;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Models.Common;
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);
// Exact release-id join. ReleaseId is a column on the track itself, so this needs no
// navigation guard — it is the authoritative alternative to the Album title match.
if (filter.ReleaseId is { } releaseId)
query = query.Where(t => t.ReleaseId == releaseId);
}
var totalCount = await query.CountAsync(ct);
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);
// Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
// All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks
// under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a
// null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the
// total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from
// the result (no present-with-zero row).
public async Task<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
{
var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);
var cutTrackCount = await Query
.CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct);
var cutReleaseTypeCounts = await releases
.Where(r => r.Medium == ReleaseMedium.Cut)
.GroupBy(r => r.ReleaseType)
.Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() })
.ToListAsync(ct);
var mixReleaseCount = await releases
.CountAsync(r => r.Medium == ReleaseMedium.Mix, ct);
var mixRuntimeSeconds = await Query
.Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix)
.SumAsync(t => t.DurationSeconds ?? 0d, ct);
return new HomeStatsDto
{
CutTrackCount = cutTrackCount,
CutReleaseTypeCounts = cutReleaseTypeCounts,
MixReleaseCount = mixReleaseCount,
MixRuntimeSeconds = mixRuntimeSeconds,
};
}
// EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list
// the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled
// at runtime: this lists which rows still need it, the backfill reads each from the vault and writes
// it back via UpdateDurationAsync.
public async Task<List<TrackEntity>> GetTracksMissingDurationAsync(CancellationToken ct = default)
=> await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct);
// Set-based duration write for one track (no load round-trip), used by the backfill. The
// DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row
// and from clobbering a value the upload path may have set in the meantime.
public async Task<int> UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
=> await Query
.Where(t => t.Id == id && t.DurationSeconds == null)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
// Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio
// path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the
// existing value because a normally-uploaded track already has a non-null DurationSeconds and the
// null-guarded backfill query would match zero rows and silently leave it stale. Returns the count
// of rows affected; zero means the track was removed between the GetById lookup and this write.
public async Task<int> SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
=> await Query
.Where(t => t.Id == id)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.DurationSeconds, durationSeconds)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
// signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
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.DurationSeconds = source.DurationSeconds;
target.ReleaseId = source.ReleaseId;
}
}
+71 -12
View File
@@ -1,5 +1,6 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Models.Converters;
namespace DeepDrftData;
@@ -9,9 +10,67 @@ 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,
EntryKey = entity.EntryKey,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
Title = entity.Title,
Artist = entity.Artist,
Genre = entity.Genre,
Description = entity.Description,
ReleaseDate = entity.ReleaseDate,
ImagePath = entity.ImagePath,
Medium = entity.Medium,
// ReleaseType is meaningful only for Cut; null it for Session/Mix at the mapping point so no
// consumer mistakes a stale studio-format value for a live/mix release.
ReleaseType = entity.Medium == ReleaseMedium.Cut ? entity.ReleaseType : (ReleaseType?)null,
SessionMetadata = entity.SessionMetadata is null
? null
: new SessionMetadataDto
{
ReleaseId = entity.SessionMetadata.ReleaseId,
HeroImageEntryKey = entity.SessionMetadata.HeroImageEntryKey
},
MixMetadata = entity.MixMetadata is null
? null
: new MixMetadataDto
{
ReleaseId = entity.MixMetadata.ReleaseId,
WaveformEntryKey = entity.MixMetadata.WaveformEntryKey
},
CreatedByUserId = entity.CreatedByUserId
};
public static ReleaseEntity Convert(ReleaseDto dto) => new()
{
Id = dto.Id,
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
// natural-key (Title/Artist) override there.
EntryKey = dto.EntryKey,
CreatedAt = dto.CreatedAt,
UpdatedAt = dto.UpdatedAt,
Title = dto.Title,
Artist = dto.Artist,
Genre = dto.Genre,
Description = dto.Description,
ReleaseDate = dto.ReleaseDate,
ImagePath = dto.ImagePath,
Medium = dto.Medium,
// Entity ReleaseType is non-nullable; default back to Single when the DTO nulled it for a
// non-Cut release. Primarily a write-path reconstruction concern.
ReleaseType = dto.ReleaseType ?? ReleaseType.Single,
CreatedByUserId = dto.CreatedByUserId
};
public static TrackDto Convert(TrackEntity entity) => new()
{
Id = entity.Id,
@@ -19,14 +78,16 @@ 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,
DurationSeconds = entity.DurationSeconds,
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 +95,9 @@ 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,
DurationSeconds = model.DurationSeconds,
ReleaseId = model.ReleaseId
};
}
+268 -6
View File
@@ -1,7 +1,9 @@
using Data.Errors;
using Data.Managers;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.Extensions.Logging;
using Models.Common;
using NetBlocks.Models;
@@ -46,6 +48,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 +99,7 @@ public class TrackManager
int pageSize,
string? sortColumn,
bool sortDescending,
TrackFilter? filter = null,
CancellationToken cancellationToken = default)
{
try
@@ -74,18 +109,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 +140,177 @@ 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 Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
var entity = TrackConverter.Convert(releaseData);
entity.Id = 0;
// Mint the public EntryKey app-side at creation — the identical call tracks make in
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
entity.EntryKey = Guid.NewGuid().ToString();
entity.Title = title;
entity.Artist = artist;
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
}
}
catch (Exception e)
{
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<ReleaseDto?>> GetReleaseByTitleAndArtist(
string title, string artist, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is null)
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
var dto = TrackConverter.Convert(existing);
dto.TrackCount = await Repository.CountLiveTracksByReleaseAsync(existing.Id, cancellationToken);
return ResultContainer<ReleaseDto?>.CreatePassResult(dto);
}
catch (Exception e)
{
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
}
}
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<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default)
{
try
{
var stats = await Repository.GetHomeStatsAsync(cancellationToken);
return ResultContainer<HomeStatsDto>.CreatePassResult(stats);
}
catch (Exception e)
{
return ResultContainer<HomeStatsDto>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default)
{
try
{
var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken);
return ResultContainer<List<TrackDto>>.CreatePassResult(
entities.Select(TrackConverter.Convert).ToList());
}
catch (Exception e)
{
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken);
return ResultContainer<int>.CreatePassResult(updated);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken);
if (affected == 0)
return ResultContainer<int>.CreateFailResult($"Duration write matched no rows for track {id}.");
return ResultContainer<int>.CreatePassResult(affected);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{
try
{
// 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)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Release.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
}
@@ -115,6 +327,30 @@ 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.Description = release.Description;
releaseEntity.ReleaseDate = release.ReleaseDate;
releaseEntity.ImagePath = release.ImagePath;
releaseEntity.Medium = release.Medium;
// DTO ReleaseType is nullable (meaningful only for Cut); the entity field is not.
// Default to Single when null, matching TrackConverter.Convert(ReleaseDto).
releaseEntity.ReleaseType = release.ReleaseType ?? ReleaseType.Single;
releaseEntity.CreatedByUserId = release.CreatedByUserId;
await Repository.UpdateReleaseAsync(releaseEntity);
}
}
var updated = await Repository.GetByIdAsync(track.Id);
return updated is not null
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
@@ -128,4 +364,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);
}
}
}
+2
View File
@@ -9,9 +9,11 @@
<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" />
<meta name="robots" content="noindex,nofollow" />
<HeadOutlet @rendermode="ServerMode" />
</head>
@@ -0,0 +1,40 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
</MudAppBar>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Small"
Class="d-flex flex-column justify-center align-center"
Style="min-height: calc(100vh - 48px);">
@Body
</MudContainer>
</MudMainContent>
</MudLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@using DeepDrftShared.Client.Common
@using AuthBlocksWeb.Components.Layout
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
<MudPopoverProvider />
@@ -8,16 +9,48 @@
<MudLayout>
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
Deep Drft — Admin
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer" />
<a href="/" class="mx-2">
<MudStack Row AlignItems="AlignItems.Center">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "
Style="filter: invert(1);"/>
<MudText Typo="Typo.button" Style="color: var(--deepdrft-white);">Deep DRFT Management</MudText>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24"
Style="filter: invert(1);"/>
</MudStack>
</a>
<MudSpacer />
<MudTooltip Text="Back to site">
<MudTooltip Text="Catalogue">
<MudIconButton Icon="@Icons.Material.Filled.Home"
Href="/"
Href="/catalogue"
Color="Color.Inherit" />
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" Variant="DrawerVariant.Responsive" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="/catalogue" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Catalogue</MudNavLink>
<MudNavLink Href="/releases" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LibraryMusic">Releases</MudNavLink>
<MudNavLink Href="/tracks/upload" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Upload</MudNavLink>
<UserAdminMenu />
<HierarchicalRoleAuthorizeView RolesList="@([SystemRoleConstants.UserAdmin])">
<Authorized>
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
</Authorized>
</HierarchicalRoleAuthorizeView>
<AccountNavMenu />
</MudNavMenu>
</MudDrawer>
<MudMainContent Class="pt-14 pb-8">
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
@Body
@@ -25,6 +58,12 @@
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
}
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
+1 -1
View File
@@ -1,6 +1,6 @@
@page "/404"
<PageTitle>SkipperHaven - Page Not Found</PageTitle>
<PageTitle>Deep DRFT Management - Page Not Found</PageTitle>
<MudText Typo="Typo.h1" Color="Color.Primary">
404 - Resource Not Found
@@ -0,0 +1,30 @@
@page "/"
@layout Layout.CmsHomeLayout
<PageTitle>Deep DRFT Management</PageTitle>
<HierarchicalRoleAuthorizeView>
<Authorized>
<RedirectToCatalogue />
</Authorized>
<NotAuthorized>
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
<MudImage Fluid="true" Src="img/cms-hero.webp" Alt="Deep Drft" />
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
Catalogue Management
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Href="@LoginHref"
Class="mt-4"
Style="min-width: 200px;">
Login
</MudButton>
</MudStack>
</NotAuthorized>
</HierarchicalRoleAuthorizeView>
@code {
private static readonly string LoginHref =
$"/account/login?returnUrl={Uri.EscapeDataString("catalogue")}";
}
+97 -5
View File
@@ -1,11 +1,103 @@
@page "/"
@page "/catalogue"
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@attribute [Authorize]
@layout Layout.CmsLayout
@inject NavigationManager Nav
@inject ICmsReleaseService CmsReleaseService
@inject ILogger<Index> Logger
<PageTitle>DeepDrft CMS</PageTitle>
<PageTitle>Deep DRFT Management - Catalogue</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudText Typo="Typo.h3" 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">
@foreach (var card in Cards)
{
<MudItem xs="12" sm="4">
@SummaryCard(card)
</MudItem>
}
</MudGrid>
</MudContainer>
@code {
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
private static readonly IReadOnlyList<MediumCard> Cards = new[]
{
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
};
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
// read (pageSize 1) for its TotalCount, run concurrently.
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
protected override async Task OnInitializedAsync()
{
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
// fetch returns, rather than blocking on the slowest of the three.
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
}
private async Task LoadCountAsync(ReleaseMedium medium)
{
try
{
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
// immaterial to the count.
var result = await CmsReleaseService.GetPagedAsync(
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
if (!result.Success)
{
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
}
}
finally
{
_loading.Remove(medium);
StateHasChanged();
}
}
private RenderFragment SummaryCard(MediumCard card) => __builder =>
{
var loading = _loading.Contains(card.Medium);
var count = _counts.GetValueOrDefault(card.Medium);
<MudCard Elevation="8" Style="height: 100%;">
<MudCardContent>
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
@if (loading)
{
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
}
else
{
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
}
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
</MudStack>
</MudCardContent>
<MudCardActions Class="justify-center pb-4">
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
View
</MudButton>
</MudCardActions>
</MudCard>
};
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
// Add Track buttons use; the Releases page reads it to set the active tab.
private static string ReleasesHref(ReleaseMedium medium) =>
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
}
@@ -0,0 +1,138 @@
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
<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="Release Name" Required="true" RequiredError="Release 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">
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
T="string" Label="Description" Lines="4" MaxLength="4000"
Variant="Variant.Outlined" Disabled="Disabled" />
</MudItem>
<MudItem xs="12" sm="6">
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
<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>
<MudDivider Class="my-4" />
<MediumFields @bind-Medium="MediumBinding"
@bind-ReleaseType="ReleaseTypeBinding"
HeroImageFile="HeroImageFile"
HeroImageFileChanged="HeroImageFileChanged"
AllowHeroUpload="AllowHeroUpload"
Disabled="Disabled" />
</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 Description { get; set; } = string.Empty;
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
[Parameter] public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
[Parameter] public EventCallback<ReleaseMedium> MediumChanged { get; set; }
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
// Session-only — the held hero-image file, threaded through MediumFields to SessionFields.
// Ignored for Cut/Mix media. The parent (BatchUpload) owns it and uploads it after create.
[Parameter] public IBrowserFile? HeroImageFile { get; set; }
[Parameter] public EventCallback<IBrowserFile?> HeroImageFileChanged { get; set; }
// Gates the hero file picker in SessionFields (threaded to MediumFields → SessionFields).
// Set true only on the BatchUpload create path; leave false/absent on all edit paths.
[Parameter] public bool AllowHeroUpload { get; set; }
// BatchEdit only: when set (and no new file picked), preview the release's current cover.
// The parent nulls this to drop the preview when the admin clears the existing cover.
[Parameter] public string? ExistingImagePath { get; set; }
[Parameter] public bool Disabled { get; set; }
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
private string? ExistingImagePreviewUrl =>
string.IsNullOrEmpty(ExistingImagePath)
? null
: $"/api/image/{Uri.EscapeDataString(ExistingImagePath)}";
// MediumFields uses two-way @bind; bridge its bindings to this component's own
// parameter/EventCallback pairs so the parent form stays the single owner of the values.
private ReleaseMedium MediumBinding
{
get => Medium;
set => MediumChanged.InvokeAsync(value);
}
private ReleaseType ReleaseTypeBinding
{
get => ReleaseType;
set => ReleaseTypeChanged.InvokeAsync(value);
}
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
SelectedImageFileChanged.InvokeAsync(e.File);
private Task ClearSelectedFile() =>
SelectedImageFileChanged.InvokeAsync(null);
}
@@ -0,0 +1,675 @@
@page "/tracks/album/{AlbumName}/edit"
@page "/tracks/{TrackId:long}/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 AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject ILogger<BatchEdit> Logger
<PageTitle>Edit Release — Deep DRFT Management</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-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@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>
}
@* Session/Mix are single-track releases (§9.3): suppress the add-track affordance and keep the
list collapsed to one row — OnMediumChanged trims rows 2..n when the medium switches to a
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
<MudGrid>
<MudItem xs="12" md="5">
@* ExistingTrackCount counts edit-session persisted rows (Id.HasValue), not authoritative
live release count — acceptable because this gate only hides a UI control; the
TrySoftDeleteEmptyReleaseAsync backstop remains the authoritative guard. *@
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_saving"
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow"
OnReplaceFileSelected="HandleReplaceFileSelected" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_saving"
ShowTrackName="@(!MediumRules.CardinalityOf(_medium).IsSingleTrack)"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</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("/releases"))"
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.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 2_000_000_000L;
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
[Parameter] public string AlbumName { get; set; } = string.Empty;
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
// the track they clicked rather than on the release with no row context. Null for the
// release-title route. The two routes are mutually exclusive — only one segment binds.
[Parameter] public long? TrackId { get; set; }
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _loading = true;
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 _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The id of the release being edited. New tracks added in this session attach to it via the upload
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
private long? _releaseId;
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
// default server-side for a non-Cut medium.
//
// Switching to a single-track medium collapses any multi-track list to the first row so the
// single-track invariant (§9.3) holds before save — the same collapse BatchUpload.OnMediumChanged
// performs, reading the same MediumRules cardinality the upload service enforces. Dropping rows
// 2..n is an in-memory trim only; existing tracks are not deleted server-side (RemoveRow owns
// deletion), so the hidden rows simply fall out of this edit session.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
protected override async Task OnInitializedAsync()
{
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
// then fall through to the shared release-load path below. The clicked track's id is held
// for row pre-selection once the list is built.
var albumName = AlbumName;
if (TrackId is { } trackId)
{
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
if (!trackResult.Success || trackResult.Value is not { } track)
{
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
_loading = false;
return;
}
albumName = track.Release?.Title;
if (string.IsNullOrEmpty(albumName))
{
_loadError = "This track has no parent release to edit.";
_loading = false;
return;
}
}
// A single page of 100 covers the full release (albums are small — same assumption as
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
var result = await CmsTrackService.GetPagedAsync(
page: 1, pageSize: 100,
sortColumn: "TrackNumber", sortDescending: false,
album: albumName);
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;
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
_releaseId = release?.Id ?? tracks[0].ReleaseId;
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
_description = release?.Description ?? string.Empty;
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
_medium = release?.Medium ?? ReleaseMedium.Cut;
_existingImagePath = release?.ImagePath;
_tracks = tracks.Select(t => new BatchRowModel
{
Id = t.Id,
EntryKey = t.EntryKey,
OriginalFileName = t.OriginalFileName,
TrackName = t.TrackName,
TrackNumber = t.TrackNumber,
WavFile = null,
Status = BatchRowStatus.Queued
}).ToList();
// Same single-track collapse on the load path, via the shared MediumRules declaration: a
// release whose stored medium is single-track surfaces only its first row for editing.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
}
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
_selectedIndex = ResolveInitialSelection();
_loading = false;
}
private int ResolveInitialSelection()
{
if (_tracks.Count == 0) return -1;
if (TrackId is { } trackId)
{
var addressed = _tracks.FindIndex(t => t.Id == trackId);
if (addressed >= 0) return addressed;
}
return 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_errorMessage = null;
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 async Task HandleReplaceFileSelected((int Index, IBrowserFile File) picked)
{
var (index, file) = picked;
if (index < 0 || index >= _tracks.Count) return;
var row = _tracks[index];
if (!row.Id.HasValue)
{
// Defensive: replace is only offered on persisted rows. A new row would have no track to
// swap against — it takes the upload path on save instead.
return;
}
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
var confirmed = await DialogService.ShowMessageBox(
"Replace audio",
$"Replace the audio for '{row.TrackName}' with '{file.Name}'? " +
"Metadata stays the same; the waveform is regenerated for the new audio.",
yesText: "Replace", cancelText: "Cancel");
if (confirmed != true) return;
row.Status = BatchRowStatus.Uploading;
row.UploadedBytes = 0;
row.TotalBytes = file.Size;
row.ErrorMessage = null;
StateHasChanged();
try
{
await using var wavStream = file.OpenReadStream(MaxUploadBytes);
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var result = await CmsTrackService.ReplaceTrackAudioAsync(
row.Id.Value, wavStream, file.Size, file.Name, file.ContentType, progress);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = error;
Snackbar.Add($"Replace failed: {error}", Severity.Error);
}
else
{
// Reset to Queued (not Done): a Done row is skipped by SaveAsync, but the admin may
// still want to save pending metadata edits. The audio swap is already persisted.
row.Status = BatchRowStatus.Queued;
row.OriginalFileName = file.Name;
Snackbar.Add($"Replaced audio for '{row.TrackName}'.", Severity.Success);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Replace audio failed for track id {Id}", row.Id);
row.Status = BatchRowStatus.Failed;
row.ErrorMessage = "Replace failed — please try again.";
Snackbar.Add("Replace failed — please try again.", Severity.Error);
}
finally
{
StateHasChanged();
}
}
private void RemoveCover()
{
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
// the existing path here drops the preview in AlbumHeaderFields.
_clearExistingImage = true;
_existingImagePath = null;
}
private async Task SaveAsync()
{
_errorMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Release 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;
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
// carry through to the stored track name.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
{
_tracks[0].TrackName = _albumName;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_saving = true;
_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,
description,
releaseDate,
imagePathForUpdate,
_releaseType,
_medium,
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).
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Re-render only on whole-percent change so a large upload paints ~100 frames,
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var uploadResult = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
album,
genre,
description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber,
_medium,
_releaseId,
progress);
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,
description,
releaseDate,
linkPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track persisted; cover can be re-linked by re-editing.
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
row.TrackName, uploadResult.Value.Id);
}
}
row.Status = BatchRowStatus.Done;
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();
}
if (failed == 0)
{
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
Navigation.NavigateTo("/releases");
}
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,43 @@
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; }
/// <summary>Bytes pushed to the wire so far for this row's in-flight upload. Reset per attempt.</summary>
public long UploadedBytes { get; set; }
/// <summary>Total payload bytes for this row (the WAV file size), the progress denominator.</summary>
public long TotalBytes { get; set; }
/// <summary>Upload completion as a 0100 percent, or 0 when the total is unknown.</summary>
public int UploadPercent => TotalBytes > 0
? (int)Math.Clamp(UploadedBytes * 100 / TotalBytes, 0, 100)
: 0;
}
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
@@ -0,0 +1,69 @@
@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">
@if (ShowTrackName)
{
<MudTextField Value="SelectedTrack.TrackName"
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
T="string"
Label="Track Name"
Required="true"
RequiredError="Track Name is required"
Variant="Variant.Outlined"
Disabled="Disabled" />
}
@if (SelectedTrack.Id.HasValue)
{
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">Use the Replace audio action in the list to swap this track's audio.</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; }
/// <summary>
/// When false (single-track Session/Mix), the Track Name field is suppressed — the name is
/// derived from the Release Name by the parent form and never entered independently.
/// Defaults to true so the Cut multi-track path is unchanged.
/// </summary>
[Parameter] public bool ShowTrackName { get; set; } = true;
private static string FormatBytes(long bytes)
{
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,133 @@
@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" />
@* Replace audio: existing (persisted) rows only. New rows still pick their WAV
via the file input above, so a replace control there would be redundant. A
native <label for> drives a per-row hidden InputFile — clicking the icon
opens that row's picker with zero JS (no eval, no programmatic .click()). *@
@if (row.Id.HasValue)
{
<label for="@ReplaceInputId(index)" @onclick:stopPropagation="true"
style="display: inline-flex; @(Disabled ? "pointer-events: none; opacity: 0.5;" : "cursor: pointer;")">
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz"
Color="Color.Primary"
Size="Size.Small"
aria-label="Replace audio" />
</label>
<InputFile id="@ReplaceInputId(index)"
OnChange="@(e => OnReplaceFileSelected.InvokeAsync((index, e.File)))"
accept=".wav,audio/wav,audio/x-wav"
disabled="@Disabled"
style="display: none;" />
}
@* Remove: hidden for the sole remaining persisted track so a release can never
be track-deleted down to zero (that path soft-deletes the whole release). New
rows are always removable — dropping one only discards a pending upload. *@
@if (!(row.Id.HasValue && ExistingTrackCount <= 1))
{
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
Disabled="@Disabled"
OnClick="@(() => OnRemove.InvokeAsync(index))"
aria-label="Remove track" />
}
</MudStack>
@if (row.Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@row.UploadPercent"
Class="mx-2 mb-2"
aria-label="@($"Uploading {row.TrackName}")" />
}
</div>
}
</MudList>
}
</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;
/// <summary>
/// Count of existing (persisted, Id-bearing) tracks in the list. When this is 1, the remove
/// control on the sole persisted row is suppressed so a release cannot be track-deleted to zero
/// (replace + release-level delete remain). New unsaved rows are excluded from this count.
/// </summary>
[Parameter] public int ExistingTrackCount { get; set; }
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
[Parameter] public EventCallback<int> OnRemove { get; set; }
/// <summary>
/// Raised when the admin picks a replacement WAV for an existing row, carrying the list index and
/// the chosen file. Only fired for persisted (Id-bearing) rows.
/// </summary>
[Parameter] public EventCallback<(int Index, IBrowserFile File)> OnReplaceFileSelected { get; set; }
// Stable per-row DOM id linking the swap-icon <label> to its hidden InputFile.
private static string ReplaceInputId(int index) => $"replace-audio-input-{index}";
private const int MaxFilesPerPick = 50;
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
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,561 @@
@page "/tracks/upload"
@using System.Security.Claims
@using DeepDrftManager.Services
@using DeepDrftModels.Enums
@using Microsoft.AspNetCore.Components.Forms
@attribute [Authorize]
@inject ICmsTrackService CmsTrackService
@inject ICmsReleaseService CmsReleaseService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILogger<BatchUpload> Logger
<PageTitle>Upload Release — Deep DRFT Management</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-Description="_description"
@bind-ReleaseDate="_releaseDate"
@bind-ReleaseType="_releaseType"
Medium="_medium"
MediumChanged="OnMediumChanged"
@bind-SelectedImageFile="_selectedImageFile"
@bind-HeroImageFile="_heroImageFile"
AllowHeroUpload="true"
Disabled="_uploading" />
@if (_medium == ReleaseMedium.Cut)
{
<MudGrid>
<MudItem xs="12" md="5">
<BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex"
Disabled="_uploading"
OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp"
OnMoveDown="MoveDown"
OnRemove="RemoveRow" />
</MudItem>
<MudItem xs="12" md="7">
<MudPaper Class="pa-4" Elevation="2">
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
Disabled="_uploading"
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
</MudPaper>
</MudItem>
</MudGrid>
}
else
{
@* Session/Mix are single-track releases — no multi-track master list. A single WAV slot. *@
<MudPaper Class="pa-4" Elevation="2">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">Track</MudText>
<InputFile OnChange="HandleSingleWavSelected" accept=".wav,audio/wav,audio/x-wav" disabled="@_uploading" />
@if (_tracks.Count > 0)
{
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
@if (_tracks[0].Status == BatchRowStatus.Uploading)
{
<MudProgressLinear Color="Color.Info"
Value="@_tracks[0].UploadPercent"
aria-label="Uploading track" />
}
}
</MudStack>
</MudPaper>
}
@if (!string.IsNullOrEmpty(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
}
@if (!string.IsNullOrEmpty(_warningMessage))
{
<MudAlert Severity="Severity.Warning" Class="mt-4">@_warningMessage</MudAlert>
}
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
<MudButton Variant="Variant.Text"
OnClick="@(() => Navigation.NavigateTo("/releases"))"
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.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 2_000_000_000L;
private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1;
private bool _uploading;
private int _uploadedCount;
private string? _errorMessage;
// Separate from _errorMessage: a soft non-blocking nudge (Severity.Warning), not a hard failure.
private string? _warningMessage;
private IBrowserFile? _selectedImageFile;
private string? _imagePath;
// Session-only: the hero image is resource-addressed and cannot be uploaded until the release
// exists, so it is held here and POSTed to api/release/{id}/session/hero-image after create.
private IBrowserFile? _heroImageFile;
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
private bool _heroWarningAcknowledged;
// Captured once at component initialization on the live interactive circuit, while the token
// is known-good, so a mid-session token expiry at submit time cannot discard a long-composed
// release. Only assigned when the id parses successfully.
private long? _createdByUserId;
private string _albumName = string.Empty;
private string _artist = string.Empty;
private string _genre = string.Empty;
private string _description = string.Empty;
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// Optional pre-select from the Add-Track buttons (§8.E): /tracks/upload?medium=session lands the
// form already in Session mode. A seed only — the medium selector stays user-changeable after load.
// Unrecognised/absent values fall through to the Cut default (same defensive posture as the API's
// TrackController.UploadTrack medium parse).
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
protected override void OnInitialized()
{
// Seed the medium from the query param so a pre-selected upload form (e.g. the Sessions tab's
// Add Track) lands already showing that medium's conditional fields. Goes through OnMediumChanged
// so the single-track collapse runs identically to a user selector change.
if (!string.IsNullOrWhiteSpace(MediumParam)
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
&& Enum.IsDefined(medium))
{
OnMediumChanged(medium);
}
}
protected override async Task OnInitializedAsync()
{
// Capture the user id once at load, while the token is known-good. The CMS host runs with
// prerender: false (InteractiveServer), so this is the single init pass — auth state is
// fully available. The page is [Authorize]-gated, so the parse should always succeed.
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (long.TryParse(userIdValue, out var userId))
{
_createdByUserId = userId;
}
}
// Switching to a single-track medium collapses any multi-track selection to the first row so the
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
// declaration the upload service enforces, so the form and the domain cannot drift.
private void OnMediumChanged(ReleaseMedium medium)
{
_medium = medium;
if (MediumRules.CardinalityOf(medium).IsSingleTrack && _tracks.Count > 1)
{
_tracks.RemoveRange(1, _tracks.Count - 1);
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
}
}
// Single-track WAV picker for Session/Mix: replaces the one row rather than appending.
private void HandleSingleWavSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
return;
}
_tracks.Clear();
_tracks.Add(new BatchRowModel
{
WavFile = file,
TrackName = Path.GetFileNameWithoutExtension(file.Name)
});
_selectedIndex = 0;
}
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
{
_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;
_warningMessage = null;
if (string.IsNullOrWhiteSpace(_albumName))
{
_errorMessage = "Release 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;
}
}
if (_createdByUserId is not long createdByUserId)
{
// _createdByUserId is set at component initialization from the authenticated principal.
// A null here means the id was unavailable even at load — a genuine configuration bug,
// since the page is [Authorize]-gated.
Logger.LogError("User id was not captured at initialization — NameIdentifier claim missing or unparseable.");
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
return;
}
// A Session's hero is its primary visual identity on the public detail page. It is optional —
// a Session can be authored without one and set later from the Sessions browser — but a missing
// hero is usually an oversight, so warn (do not block). The first submit without a hero shows the
// warning and primes acknowledgment; a second submit proceeds.
if (_medium == ReleaseMedium.Session && _heroImageFile is null && !_heroWarningAcknowledged)
{
_heroWarningAcknowledged = true;
_warningMessage = "No hero image selected. A Session usually needs one — you can add it now, "
+ "or submit again to create the Session without it (set the hero later from the Sessions browser).";
return;
}
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
// the admin is not surprised at the end of a long upload. The server backstops this on the
// create path, but checking here keeps the failure fast and visible. The values passed match
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
if (!duplicateCheck.Success)
{
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Could not verify the release name: {checkError}";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
if (duplicateCheck.Value is { } existing)
{
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
{
_tracks[0].TrackName = _albumName;
}
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
_uploading = true;
_uploadedCount = 0;
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;
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
// reaches row 2 (single-track media collapse to one row).
long? batchReleaseId = null;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
var trackNumber = i + 1; // 1-based ordinal from list position
row.Status = BatchRowStatus.Uploading;
StateHasChanged();
row.UploadedBytes = 0;
row.TotalBytes = row.WavFile!.Size;
try
{
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
// service wraps it in ProgressStreamContent so the whole file is never materialised
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
var lastPercent = -1;
var progress = new Progress<long>(written =>
{
row.UploadedBytes = written;
if (row.UploadPercent != lastPercent)
{
lastPercent = row.UploadPercent;
StateHasChanged();
}
});
var result = await CmsTrackService.UploadTrackAsync(
wavStream,
row.WavFile.Size,
row.WavFile.Name,
row.WavFile.ContentType,
row.TrackName,
_artist,
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
string.IsNullOrWhiteSpace(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
row.WavFile.Name,
createdByUserId,
_releaseType,
trackNumber,
_medium,
batchReleaseId,
progress);
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
{
// Capture the release id created by the first successful row so subsequent rows
// attach to it (the within-batch multi-track Cut path). Only set once — later
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
// which is correct: a release-less upload has no within-batch release to attach to.
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
{
batchReleaseId = createdReleaseId;
}
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern BatchEdit uses.
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(_description) ? null : _description,
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
imgPath,
_releaseType,
_medium,
trackNumber);
if (!linkResult.Success)
{
// Non-blocking: track is persisted; cover art can be re-linked by editing.
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
row.TrackName, result.Value.Id);
}
}
// Session hero image is resource-addressed, so it is uploaded here — after the
// release exists and we have its id — within the same submit gesture. Non-blocking:
// the Session is persisted; a failed hero upload is recoverable from the Sessions
// browser's per-row Set/Replace hero action.
if (_medium == ReleaseMedium.Session
&& _heroImageFile is { } heroFile
&& result.Value.ReleaseId is { } sessionReleaseId)
{
try
{
await using var heroStream = heroFile.OpenReadStream(maxAllowedSize: 50_000_000);
var heroResult = await CmsReleaseService.UploadSessionHeroImageAsync(
sessionReleaseId, heroStream, heroFile.Name, heroFile.ContentType);
if (!heroResult.Success)
{
var heroError = heroResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
Logger.LogWarning("Batch upload: hero image upload failed for release {ReleaseId} ('{TrackName}'): {Error}",
sessionReleaseId, row.TrackName, heroError);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
catch (Exception heroEx)
{
Logger.LogError(heroEx, "Batch upload: exception uploading hero image for release {ReleaseId}", sessionReleaseId);
Snackbar.Add("Session uploaded, but the hero image failed. Set it from the Sessions browser.", Severity.Warning);
}
}
else if (_medium == ReleaseMedium.Session && _heroImageFile is not null)
{
// ReleaseId was null on a Session track result — internal inconsistency.
// Hero file is held but cannot be uploaded without a release id; log and
// surface so the admin can set it from the Sessions browser.
Logger.LogWarning("Batch upload: Session track '{TrackName}' (id={Id}) has no ReleaseId — hero image dropped",
row.TrackName, result.Value.Id);
Snackbar.Add("Session uploaded, but the hero image could not be linked (no release id). Set it from the Sessions browser.", Severity.Warning);
}
// Mix uploads fire the server-side high-res waveform trigger (§3.4). The CMS
// computes nothing — the API derives the datum from the audio it just stored.
// Non-blocking: the track is persisted; a failed trigger is recoverable from
// the Mixes browser's per-row Generate action.
if (_medium == ReleaseMedium.Mix && result.Value.ReleaseId is { } mixReleaseId)
{
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
if (!waveformResult.Success)
{
Logger.LogWarning("Batch upload: mix waveform trigger failed for release {ReleaseId} ('{TrackName}')",
mixReleaseId, row.TrackName);
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
}
}
row.Status = BatchRowStatus.Done;
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);
Navigation.NavigateTo("/releases");
}
else
{
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
finally
{
_uploading = false;
StateHasChanged();
}
}
}

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