58 Commits

Author SHA1 Message Date
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
129 changed files with 8507 additions and 240 deletions
+1
View File
@@ -9,3 +9,4 @@
*.conf text eol=lf *.conf text eol=lf
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout # Vendor JS pinned LF — avoids CRLF churn on Windows checkout
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
+1
View File
@@ -318,3 +318,4 @@ Database/Vaults/*
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest; # 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. # gitignored TS output is absent when manifest is generated, so absent from publish output.
!DeepDrftShared.Client/wwwroot/js/parallax/ !DeepDrftShared.Client/wwwroot/js/parallax/
!DeepDrftShared.Client/wwwroot/js/knob/
+7 -7
View File
@@ -9,11 +9,11 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects ### 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**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Consumed by the public site. - **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. 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). - **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces. - **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests. - **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). 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. 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. - **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. - **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this. - **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation. - **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
@@ -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 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`. - 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` - Context: `DeepDrftContext` in `DeepDrftData`
2. **FileDatabase**: Custom file-based binary storage system 2. **FileDatabase**: Custom file-based binary storage system
@@ -114,10 +114,10 @@ dotnet run --project DeepDrftAPI
### Entity Framework (SQL Database) ### Entity Framework (SQL Database)
```bash ```bash
# Add migration (from solution root) # 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 # Update database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
``` ```
## Key Configuration Files ## Key Configuration Files
@@ -125,7 +125,7 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files: 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`). - `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 600 — budget for server-side persist after the body is fully sent). - `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds). - `DeepDrftAPI/appsettings.json`: Logging and hosting config. 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 ## Folder-Level Guidance
+149
View File
@@ -6,6 +6,155 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
--- ---
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The append-only Add-to-Queue affordance on detail pages — a new shared `AddToQueueButton.razor` control wired at every detail-page play site, enabling listeners to add a release or individual track to the queue without interrupting the current track. `ReleaseGallery` browse-grid cards are intentionally excluded (OQ10, deferred).
- **Why:** Phase 11 wave 11.F built the `Enqueue`/`EnqueueRange` append path in the queue engine but gave it no UI entry point. Wave 17.4 lights that dormant path, completing the Add-to-Queue capability Daniel stated as commitment 4 of Phase 17. It was split from 17.2 because it depends only on the existing engine append members (not on 17.1's new `Move`/`RemoveAt`), allowing it to land in parallel.
- **Shape:**
- **`AddToQueueButton.razor`** (`DeepDrftPublic.Client/Controls/AddToQueueButton.razor`): shared append-only button with two modes: track mode (`Enqueue` — called with a single `TrackDto`) and release mode (`EnqueueRange` — called with an ordered `IReadOnlyList<TrackDto>`). Material `PlaylistAdd` glyph; tooltip reads "Add to queue" (track mode) or "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive / when the cascade is absent; append-only — does not play, does not navigate.
- **`CutDetail.razor`** (header): release-mode `AddToQueueButton` beside the header play affordance, passing the `TrackNumber`-ordered track list.
- **`CutDetail.razor`** (track rows): track-mode `AddToQueueButton` beside the per-row play affordance.
- **`SessionDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Session hero play button.
- **`MixDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Mix hero play button.
- **Excluded sites:** `StreamNowButton` (no fixed track to resolve — OQ9) and `ReleaseGallery` cards (no play button today — OQ10, deferred to `TODO.md`).
---
## Phase 17 — Player-Bar Queue View: Wave 17.2 — Docked queue overlay (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The editable docked-player queue overlay — a Queue toggle button in the non-Fixed (docked) player bar and a new `QueueOverlay.razor` modal that hosts the shared `QueueList` in editable mode. Listeners can now see, reorder, remove from, and jump within the queue while a release is playing. Also fixed a pre-existing `QueueChanged` unsubscribe leak in `AudioPlayerBar.DisposeAsync`, hardened `PlayRelease` with a defensive copy, and styled the global `deepdrft-queue-*` CSS classes for the first time (first styling for the `QueueList` classes that 17.1 shipped unstyled).
- **Why:** Phase 11 built the queue engine and Phase 17 wave 17.1 built the shared `QueueList` component, but neither surfaced the queue visually in the docked player. Wave 17.2 delivers Daniel's commitment 2 — a visible, editable queue panel in the non-Fixed player bar.
- **Shape:**
- **Queue toggle button** (`AudioPlayerBar` / `PlayerTransportZone`): shown only when `!Fixed && Items.Count > 0`, placed below the transport-button row and left of the timestamp. Material `QueueMusic` glyph; renders in an active/highlighted state when the overlay is open.
- **`QueueOverlay.razor`** (`DeepDrftPublic.Client/Controls/QueueOverlay.razor`): screen-centered tinted modal borrowing the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`). Panel stops click propagation; scrim-click closes the overlay (drag-safe: capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes if a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode.
- **`AudioPlayerBar` wiring**: reorder → `Move(fromIndex, toIndex)`; remove → `RemoveAt(index)` (auto-closes overlay when queue empties); row-jump → `PlayRelease(Items, index)`; Clear header action → new `ClearUpcoming()`. Fixed pre-existing `QueueChanged` unsubscribe leak in `DisposeAsync`.
- **`QueueList.razor` — current-row remove suppression**: the remove (×) control is now hidden on the currently-playing row (`Editable && !isCurrent`), enforcing OQ3's "the current track cannot be removed" rule in the UI. Reorder of the current row is still permitted.
- **Engine — `ClearUpcoming()`** (`IQueueService` / `QueueService`): new additive member. Removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0`; re-emits `QueueChanged`; touches no playback. Satisfies OQ5's requirement that Clear does not stop or remove the current track.
- **Engine — `PlayRelease` defensive copy**: `PlayRelease` now always materializes a defensive copy of its input list (`tracks.ToList()`) so it can never alias the caller's `Items` list — fixes a row-jump bug where jumping via `PlayRelease(Items, index)` could mutate the live `Items` reference mid-operation.
- **CSS — `deepdrft-queue-*` classes** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): overlay/list chrome classes added to the global stylesheet (portaled overlay content cannot use scoped CSS). This is also the first styling pass for the `QueueList` classes 17.1 introduced without accompanying styles.
---
## Phase 17 — Player-Bar Queue View: Wave 17.1 — Engine additions + shared QueueList (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The queue-engine additions and the shared presentational list component that waves 17.2 and 17.3 will consume. No player-bar wiring, overlay, embed, or Add-to-Queue affordance landed — those remain in waves 17.2 and 17.3.
- **Why:** Wave 17.1 is the cold-start prerequisite for the full Phase 17 queue-view surface. The engine additions are interop-free state mutations that land without any UI decision being made; `QueueList` is the single presentational "view" both the docked overlay and the embedded panel will share (one source, multiple views), so it is cleanest to build and test it before the hosting contexts exist.
- **Shape:**
- **Engine — `Move(int fromIndex, int toIndex)`** (`IQueueService` / `QueueService`): reorders `Items` in-place, adjusts `CurrentIndex` so the same track stays current across the move, re-emits `QueueChanged`. Never re-streams or interrupts the playing track. No-op (no `QueueChanged`) when either index is out of range or the indices are equal. Interop-free; safe during prerender.
- **Engine — `RemoveAt(int index)`** (`IQueueService` / `QueueService`): removes the item at `index`, adjusts `CurrentIndex` (a track before current decrements the index; a track after current leaves it unchanged; removing the current track does not stop playback — the track runs to natural end while `CurrentIndex` resolves to the new slot occupant; removing the last remaining item leaves the queue empty and dormant with `CurrentIndex == -1`). Re-emits `QueueChanged`. No-op when `index` is out of range. Interop-free; safe during prerender.
- **Engine — dormant-`Enqueue` coherence (OQ8):** `Enqueue` and `EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) now set `CurrentIndex` to 0 so a subsequent play/skip is correct. Does **not** auto-play — add is not play. `PlayCurrent` is never called from these paths; the methods remain interop-free.
- **`QueueList.razor`** (`DeepDrftPublic.Client/Controls/QueueList.razor`): purely presentational component. Renders `Items` as an ordered list with the current track marked (position number + `GraphicEq` now-playing icon on the current row). `Editable` flag gates drag-reorder handles and per-row remove controls: when `true`, wraps rows in a `MudDropContainer`/`MudDropZone` for reorder; when `false`, renders a plain `<div>` (read-only; the embed's fixed-order shared queue). Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump` respectively — the component calls no `IQueueService` method itself. Owns no data fetch or player wiring. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
- **`QueueServiceTests`**: T1T10 added, covering `Move` (in-range, out-of-range, same-index no-ops; current-track identity preserved across reorders) and `RemoveAt` (before/after/at current; last-item dormant; out-of-range no-op; playback not stopped).
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.5 — Home Plays-card capstone (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The capstone wave — the live home hero Plays card and the `HomeStatsDto` extension that powers it. `NowPlayingStats.razor`'s third card, previously a static "XXX / Plays (Coming Soon)" odometer placeholder, now renders the live `TotalPlays` figure in the existing odometer treatment with a secondary "N listeners" line (`UniqueListeners`). No new fetch path, no new client service, no migration — the card consumes the same `HomeStatsDto` round-trip the other two cards already use. Privacy footer line (`DeepDrftFooter.razor`, `.deepdrft-footer-privacy`) also landed as part of the same merge: a quiet fine-print disclosure of the anonymous `anonId` token, using the Variant 1 approved copy from `product-notes/phase-16-privacy-note.md`.
- **Why:** The Plays card was deliberately held as the final wave (sequenced bottom-up per Daniel's directive) so the substrate (16.1 capture + rollup, 16.2 bucket/channel, 16.3 anonId + distinct-listener aggregation) would be solid before any read surface appeared. Wave 16.4 (per-target / CMS stats views) was speculative and skipped; the event log supports it later if wanted. With 16.5 landing, Phase 16 is complete.
- **Shape:**
- **`HomeStatsDto` extended** (`DeepDrftModels/DTOs/HomeStatsDto.cs`): two new fields — `TotalPlays` (`long`; site-wide sum of every `play_counter` row's `PartialCount + SampledCount + CompleteCount`, all-time; zero until the telemetry migration is applied — expected, not an error) and `UniqueListeners` (`int`; distinct non-null `anon_id` across all play events, all-time; over-counts by design, honestly labelled "listeners"). No other DTO changes.
- **`StatsController` composition** (`DeepDrftAPI/Controllers/StatsController.cs`): now injects `ITrackService` (existing) **and** `IEventService` (Phase 16 event domain). `GetHome` assembles `HomeStatsDto` in two sequential best-effort reads: track-domain aggregation via `ITrackService.GetHomeStats` (existing; failure returns 500 as before); play/listener figures via `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16; a telemetry failure or not-yet-applied migration leaves them at 0 rather than 500-ing the whole endpoint). Neither domain reaches into the other's tables; the controller is the composition seam only.
- **`IEventService` additions** (`DeepDrftData/IEventService.cs`): `GetTotalPlayCount(ct)``ResultContainer<long>` and `GetDistinctListenerCount(ct)``ResultContainer<int>` (wave 16.3 added the distinct-listener overloads; `GetTotalPlayCount` is the one new member for 16.5).
- **`EventRepository.CountTotalPlaysAsync`** (`DeepDrftData/Repositories/EventRepository.cs`): sums `PartialCount + SampledCount + CompleteCount` directly over `PlayCounters` via LINQ — **not** `PlayCounter.TotalPlays` (which is an EF-ignored computed property and not translatable). An empty counter table sums to 0.
- **`NowPlayingStats.razor`** (`DeepDrftPublic.Client/Controls/NowPlayingStats.razor`): third card now renders `@_stats.TotalPlays` in `.hero-stat-odometer` and `@_stats.UniqueListeners listeners` as `.hero-stat-sub`. No change to the `PersistentComponentState` bridge or `IStatsDataService` fetch path — the DTO fields arrive in the same existing round-trip.
- **`DeepDrftFooter.razor`** (`DeepDrftPublic.Client/Layout/DeepDrftFooter.razor`): privacy disclosure paragraph (`.deepdrft-footer-privacy`) added: "We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag's gone."
- **No migration.** The `play_counter` rollup table was created by `20260619155610_AddPlayShareTelemetry` (wave 16.1; authored, not yet applied — Daniel-gated). The `CountTotalPlaysAsync` query returns 0 gracefully until that migration runs.
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.1 — Foundation (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The anonymous telemetry **substrate** — foundation end-to-end with nothing reading it yet. No `anonId` written; no home-card/read surface changed (those are waves 16.3 and 16.5). The full capture-and-storage pipeline is in place: client-side play-session tracker and share tracker, `sendBeacon` transport with page-unload handler, proxied and rate-limited intake endpoints, append-only SQL event log with incremental rollup, and server-side release attribution.
- **Why:** The home hero's Plays stat card (`NowPlayingStats.razor`'s third card) has been a static "XXX / Plays (Coming Soon)" placeholder. Phase 16 builds the anonymous, privacy-light substrate that will eventually power it. Wave 16.1 is the cold-start foundation — nothing reads the log yet; correctness and storage are the deliverable, not the visible metric.
- **Shape:**
- **Client — `PlayTracker`** (`DeepDrftPublic.Client/Services/PlayTracker.cs`): opens a play session on playback start, advances a high-water position on each progress tick (instrumented at `StreamingAudioPlayerService`, not at the HTTP layer, so seek-beyond-buffer re-fetches count as the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3s OR ≥5% of duration (whichever is smaller). Three-bucket classification: `partial` < 30%, `sampled` 3080%, `complete` > 80%. Emits at most one event per session via `IPlayEventSink`. Deliberately free of player, HTTP, and JS dependencies for testability.
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce so repeated copies of the same link in a session count as one share. Sends via `sendBeacon`. No `anonId` in wave 16.1.
- **Client — `BeaconInterop`** (`DeepDrftPublic.Client/Services/BeaconInterop.cs`): `navigator.sendBeacon` JS interop wrapper + page-unload handler that flushes any pending play event when the page is torn down.
- **Public proxy — `EventProxyController`** (`DeepDrftPublic/Controllers/EventProxyController.cs`): proxies `POST api/event/play` and `POST api/event/share` to DeepDrftAPI. Buffers and relays the small JSON body verbatim; forwards `X-Forwarded-For` for per-IP rate limiting on the API side. Opts out of antiforgery (`[IgnoreAntiforgeryToken]`) — `sendBeacon` cannot attach tokens.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `POST api/event/play` and `POST api/event/share`, unauthenticated, rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, registered in `Program.cs`). Returns `202 Accepted` (fire-and-forget contract). Payload-validates the track key and enum values; delegates writes to `IEventService`.
- **API — rate limiter** (`DeepDrftAPI/Program.cs`): `AddRateLimiter` + `"events"` fixed-window policy keyed on `Connection.RemoteIpAddress`; `UseForwardedHeaders` in production resolves the XFF chain into the real client IP. `UseRateLimiter()` added to the middleware pipeline.
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): append-only writes to `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository stamps the release id.
- **Data — `EventManager` / `IEventService`** (`DeepDrftData/EventManager.cs`): `IEventService` boundary (`RecordPlay`, `RecordShare`); `EventManager` wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs` alongside the existing track and release domain services.
- **Migration `20260619155610_AddPlayShareTelemetry`**: adds `play_event`, `share_event`, and `play_counter` tables. **Authored but not yet applied** (Daniel-gated).
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.3 — Unique-listener `anonId` layer (landed 2026-06-19)
**Landed:** 2026-06-19 on dev (merge `297805b`). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the wave 16.1 migration.
- **What:** The unique-listener `anonId` seam end-to-end — the "last metric layer" of the Phase 16 substrate. Client mints a first-party `localStorage` GUID on first visit, threads it onto play and share beacon payloads (omitted when null), server accepts and length-clamps it (reject-not-truncate, ≤64 chars), persists it to the reserved nullable `anon_id` columns, and exposes all-time distinct-listener aggregation. The distinct-count capability is in place but not yet surfaced on any read surface (16.5 consumes it). Privacy-notice copy deliberately not authored (Daniel-gated).
- **Why:** The anonymous unique-listener metric (D5 / D3) is the final substrate wave before the home Plays card can be lit (16.5). It was sequenced last of the metric layers because it is the lowest-priority metric and carries no dependency — the event log captures `anon_id` on the same rows 16.1 already writes; 16.3 simply lights the seam that was reserved but unused.
- **Shape:**
- **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`.
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
- **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically).
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink.
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): three new distinct-count queries (already in the repository as of 16.3): `CountDistinctListenersAsync()` (site-wide, nulls excluded), `CountDistinctListenersForTrackAsync(trackEntryKey)` (per-track), `CountDistinctListenersForReleaseAsync(releaseId)` (per-release, uses the stamped `release_id` on the play event row — D4 attribution).
- **Data — `IEventService` / `EventManager`** (`DeepDrftData/EventManager.cs`): three new members exposing the distinct-count capability: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)` — each returns `ResultContainer<int>`. No read surface or card consumes them yet (16.5).
- **No migration** — the `anon_id varchar(64)` columns on `play_event` and `share_event` and their covering indexes (`IX_play_event_anon_id`, `IX_share_event_anon_id`) were already created by `20260619155610_AddPlayShareTelemetry` (wave 16.1). Wave 16.3 only wires the client seam and adds the server-side aggregation queries.
---
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.2 — Completion-bucket classification + shares (landed by absorption into 16.1)
**Landed:** absorbed into wave 16.1 (2026-06-19). All §4.1 deliverables shipped inside the foundation wave.
- **What:** Three-bucket completion classification correct and exhaustive end to end, and share-channel split. Because these were structurally inseparable from the foundation (the tracker, payload, log table, and rollup all required the bucket column set from day one), they landed together with 16.1 rather than as a follow-on wave.
- **Why:** The §4.2 spec listed bucket classification and share-channel split as wave 16.2 items, but the implementation showed they could not be cleanly deferred — the `play_counter` rollup columns are per-bucket by design (D6), and the share `channel` discriminator is a single non-null column on the `share_event` table. Building the log without them would have required a migration to add them in 16.2 anyway.
- **Shape:**
- **`PlayBucket` enum** (`DeepDrftModels.Enums`): `Partial` (< 30%), `Sampled` (3080%), `Complete` (> 80%) — exhaustive, non-overlapping. D1 resolved.
- **`PlayCounter` rollup columns** (`DeepDrftModels.Entities.PlayCounter`): `PartialCount`, `SampledCount`, `CompleteCount` (each `long`), `TotalPlays` (computed `long` sum). `BumpCounterAsync` in `EventRepository` switches on the bucket to increment the correct column in the same transaction as the event append.
- **API-boundary bucket validation** (`EventController`): `Enum.IsDefined(payload.Bucket)` guard — an undefined bucket value returns `400 Bad Request` before the write reaches the repository.
- **`ShareChannel` enum** (`DeepDrftModels.Enums`): `Link` / `Embed` on `ShareEvent.Channel`. `ShareTracker` passes the channel through from the `SharePopover` clipboard action; `EventController` validates it is a defined `ShareChannel` value.
- **Deferred:** optional `share_count` rollup column on `play_counter` (per-track share count in the rollup table) — not built. Shares are not on the home-card hot path; per-target share reads are speculative wave 16.4 work.
---
## Home Hero Stats — Live data wiring (landed 2026-06-18)
**Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`).
- **What:** Replaced the hard-coded placeholder figures in the public home hero stat row (`NowPlayingStats`) with real SQL-backed aggregates. Resolves the "Real stat-row numbers" deferred item from Phase 0 §0.3.
- **Why:** The stat row ("47+ / 2 / ∞") was intentionally hard-coded at Phase 0 with a TODO; the data model now has enough shape (releases, medium discriminator, trackrelease join) to serve real numbers in a single efficient query.
- **Shape:**
- **New SQL column:** `DurationSeconds` (`double?`, column `duration_seconds`) on `TrackEntity` and `TrackDto`. Populated at upload via the existing dual-database add flow (`TrackContentService` extracts duration from vault audio; `UnifiedTrackService` persists it to SQL). Migration `20260618155002_AddTrackDuration`. Configured in `TrackConfiguration`.
- **New aggregate query:** `TrackRepository.GetHomeStatsAsync``HomeStatsDto` (new DTO in `DeepDrftModels/DTOs/`). Returns cut track count, per-`ReleaseType` cut release counts (zero-count types suppressed), mix release count, and total mix runtime seconds (null durations counted as 0; tracks under soft-deleted releases excluded). Surfaced via `ITrackService.GetHomeStats` on `TrackManager`.
- **New API endpoints:** `GET api/stats/home` (`StatsController`, unauthenticated; returns `HomeStatsDto` bare) and `POST api/track/duration/backfill` (ApiKey-gated; one-time backfill of `DurationSeconds` for pre-existing rows from vault audio, delegated to `UnifiedTrackService.BackfillDurationsAsync`).
- **New public proxy:** `StatsProxyController` in `DeepDrftPublic` mirrors `ReleaseProxyController`; forwards `GET api/stats/home` from the browser to DeepDrftAPI.
- **New client surface:** `StatsClient` (`Clients/`, named `"DeepDrft.API"` client) + `IStatsDataService` / `StatsClientDataService` (`Services/`) registered scoped in `Startup.ConfigureDomainServices`. `RuntimeFormat` static helper (`Helpers/`) converts seconds to `hh:mm`.
- **`NowPlayingStats.razor`:** now renders live data — Studio Cuts card (cut track count + zero-suppressed Single/EP/Album breakdown), Mixes card (`MixReleaseCount` "Sets" + `hh:mm` runtime), Plays card (static "XXX / Coming Soon" odometer placeholder). Uses `PersistentComponentState` to bridge the SSR prerender fetch across the WASM seam (only persists on a successful load).
---
## Phase 12 — About Page (public site editorial) (landed 2026-06-17) ## Phase 12 — About Page (public site editorial) (landed 2026-06-17)
**Landed:** 2026-06-17 on dev. **Landed:** 2026-06-17 on dev.
+60 -1
View File
@@ -110,6 +110,16 @@ Admin backfill view: returns every track with flags indicating whether each wave
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault). - **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. - 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.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize]) ### 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). Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
@@ -147,7 +157,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized. - `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. - `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure. - The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory. - `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). - Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails. - Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
@@ -160,6 +170,17 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey. - Calls `UnifiedTrackService.DeleteAsync`, which: looks up SQL row → deletes SQL row → deletes vault entry via EntryKey.
- Returns 200 on success, 404 if track not found, 500 if deletion fails. - Returns 200 on success, 404 if track not found, 500 if deletion fails.
### POST api/track/{id:long}/replace-audio ([ApiKeyAuthorize])
**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 temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- 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) ### GET api/track/page (unauthenticated)
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`. Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
@@ -272,6 +293,44 @@ Stores a hero image in the `images` vault and links it via `SessionMetadata.Hero
- Validates MIME type (rejects unsupported types with `.bin` sentinel). Calls `UnifiedReleaseService.SetHeroImageAsync`. - 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. - 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 ## ApiKey middleware behaviour
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata. `ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
+114
View File
@@ -0,0 +1,114 @@
using DeepDrftData;
using DeepDrftModels.DTOs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace DeepDrftAPI.Controllers;
/// <summary>
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
/// </summary>
[ApiController]
[Route("api/event")]
[EnableRateLimiting("events")]
public class EventController : ControllerBase
{
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
// payloads are a track key + an enum, well under 1 KB.
private const int MaxBodyBytes = 1024;
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
private const int MaxAnonIdLength = 64;
private readonly IEventService _eventService;
private readonly ILogger<EventController> _logger;
public EventController(IEventService eventService, ILogger<EventController> logger)
{
_eventService = eventService;
_logger = logger;
}
// POST api/event/play (unauthenticated, rate-limited)
[HttpPost("play")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
{
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
// keep the contract obvious and cover the empty-string key the model binder lets through.
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
return BadRequest("trackEntryKey is required");
if (!Enum.IsDefined(payload.Bucket))
return BadRequest("bucket is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
if (!result.Success)
{
// A telemetry failure must never surface to the listener as an error they can act on, but
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
// beacon ignores the status either way.
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// POST api/event/share (unauthenticated, rate-limited)
[HttpPost("share")]
[RequestSizeLimit(MaxBodyBytes)]
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(payload.TargetKey))
return BadRequest("targetKey is required");
if (!Enum.IsDefined(payload.TargetType))
return BadRequest("targetType is invalid");
if (!Enum.IsDefined(payload.Channel))
return BadRequest("channel is invalid");
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
return BadRequest("anonId is invalid");
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
if (!result.Success)
{
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
return StatusCode(500);
}
return Accepted();
}
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
// over the column width is rejected (400) rather than truncated, since truncation would collide
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
{
if (string.IsNullOrWhiteSpace(raw))
{
anonId = null;
return true;
}
var trimmed = raw.Trim();
if (trimmed.Length > MaxAnonIdLength)
{
anonId = null;
return false;
}
anonId = trimmed;
return true;
}
}
@@ -0,0 +1,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);
}
}
+107 -6
View File
@@ -173,19 +173,40 @@ public class TrackController : ControllerBase
return Ok(status); 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. // 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 // 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. // proxies the upload here so it never touches the vault disk path or SQL directly.
// UnifiedTrackService owns the two-database write. // UnifiedTrackService owns the two-database write.
// //
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds // RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams // hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling, // outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
// not a buffered allocation. // 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] [ApiKeyAuthorize]
[HttpPost("upload")] [HttpPost("upload")]
[RequestSizeLimit(1_073_741_824)] [RequestSizeLimit(2_000_000_000)]
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)] [RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack( public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
[FromForm] IFormFile? audioFile, [FromForm] IFormFile? audioFile,
[FromForm] string? trackName, [FromForm] string? trackName,
@@ -479,6 +500,86 @@ public class TrackController : ControllerBase
return StatusCode(500, error); 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");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, 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
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
}
}
}
// DELETE api/track/release/{id} ([ApiKeyAuthorize]) // DELETE api/track/release/{id} ([ApiKeyAuthorize])
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release // 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 // (one with no live tracks). "release" is a literal segment, declared here in the literal-route
+34
View File
@@ -8,8 +8,10 @@ using DeepDrftData;
using DeepDrftData.Data; using DeepDrftData.Data;
using DeepDrftData.Repositories; using DeepDrftData.Repositories;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NetBlocks.Utilities.Environment; using NetBlocks.Utilities.Environment;
using System.Threading.RateLimiting;
// Required credential files — must exist before the app will start. // 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. // Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
@@ -64,6 +66,14 @@ builder.Services
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>()); .AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
builder.Services.AddScoped<UnifiedTrackService>(); 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 // Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes. // IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
builder.Services builder.Services
@@ -118,6 +128,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
options.KnownProxies.Clear(); 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(); var app = builder.Build();
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot. // Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
@@ -136,6 +165,11 @@ if (app.Environment.IsDevelopment())
app.UseCors("ContentApiPolicy"); 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 // 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 // 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. // carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
+112
View File
@@ -166,6 +166,70 @@ public class UnifiedTrackService
return saveResult; return saveResult;
} }
/// <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 // 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 // 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 // consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
@@ -193,6 +257,54 @@ public class UnifiedTrackService
} }
} }
/// <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> /// <summary>
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete /// 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 /// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
+82 -1
View File
@@ -74,7 +74,10 @@ public class TrackContentService
{ {
EntryKey = trackId, // FileDatabase entry ID EntryKey = trackId, // FileDatabase entry ID
TrackName = trackName, TrackName = trackName,
OriginalFileName = originalFileName OriginalFileName = originalFileName,
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
// need not touch the vault. Same value the high-res waveform compute reads downstream.
DurationSeconds = audioBinary.Duration
}; };
return trackEntity; return trackEntity;
@@ -100,6 +103,84 @@ public class TrackContentService
string? originalFileName = null) => string? originalFileName = null) =>
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName); 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;
}
}
/// <summary> /// <summary>
/// Retrieves audio binary from FileDatabase /// Retrieves audio binary from FileDatabase
/// </summary> /// </summary>
+21 -7
View File
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
DeepDrftData/ DeepDrftData/
├── Data/ ├── Data/
│ ├── DeepDrftContext.cs # EF DbContext │ ├── 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/ │ └── Configurations/
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity │ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations) ├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
@@ -32,7 +32,7 @@ DeepDrftData/
## EF DbContext and configuration ## 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: `TrackConfiguration` uses EF fluent API:
- Table name: `track` (singular) - Table name: `track` (singular)
@@ -42,6 +42,7 @@ DeepDrftData/
- `Album`, `Genre`: optional, max 200 / 100 - `Album`, `Genre`: optional, max 200 / 100
- `ReleaseDate`: optional `DateOnly` - `ReleaseDate`: optional `DateOnly`
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future) - `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 ## Service → Repository → DbContext shape
@@ -49,6 +50,19 @@ DeepDrftData/
- **Repository** (`TrackRepository`): Internal data access. Queries the DbContext. Throws on error (service catches). - **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). - **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).
## 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: Example:
```csharp ```csharp
@@ -117,10 +131,10 @@ Run from the solution root:
```bash ```bash
# Add a migration # 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 # 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). 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 +146,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
## Connection string ## Connection string
- **DeepDrftAPI**: `environment/connections.json``ConnectionStrings:DefaultConnection` - **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 ## Service registration
@@ -142,7 +156,7 @@ In `DeepDrftAPI/Program.cs`:
```csharp ```csharp
services.AddDbContext<DeepDrftContext>(options => services.AddDbContext<DeepDrftContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<TrackRepository>(); services.AddScoped<TrackRepository>();
services.AddScoped<TrackManager>(); services.AddScoped<TrackManager>();
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>()); services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
@@ -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,48 @@
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DeepDrftData.Data.Configurations;
/// <summary>
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
/// entity. Indexed on the target key so per-target share tallies stay cheap.
/// </summary>
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
{
public void Configure(EntityTypeBuilder<ShareEvent> builder)
{
builder.ToTable("share_event");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TargetType)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("target_type");
builder.Property(e => e.TargetKey)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("target_key");
builder.Property(e => e.Channel)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(20)
.HasColumnName("channel");
builder.Property(e => e.AnonId)
.HasMaxLength(64)
.HasColumnName("anon_id");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasColumnName("created_at");
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
}
}
@@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
.HasColumnName("track_number") .HasColumnName("track_number")
.HasDefaultValue(1); .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) builder.Property(e => e.ReleaseId)
.HasColumnName("release_id"); .HasColumnName("release_id");
+9
View File
@@ -15,6 +15,12 @@ public class DeepDrftContext : DbContext
public DbSet<SessionMetadata> SessionMetadata { get; set; } public DbSet<SessionMetadata> SessionMetadata { get; set; }
public DbSet<MixMetadata> MixMetadata { 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -23,5 +29,8 @@ public class DeepDrftContext : DbContext
modelBuilder.ApplyConfiguration(new ReleaseConfiguration()); modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration()); modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration()); modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
} }
} }
+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);
}
+27
View File
@@ -28,6 +28,33 @@ public interface ITrackService
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary> /// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default); 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> /// <summary>
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating /// 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 /// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
@@ -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");
}
}
}
@@ -67,6 +67,94 @@ namespace DeepDrftData.Migrations
b.ToTable("mix_metadata", (string)null); 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 => modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -209,6 +297,53 @@ namespace DeepDrftData.Migrations
b.ToTable("session_metadata", (string)null); 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 => modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -222,6 +357,10 @@ namespace DeepDrftData.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision")
.HasColumnName("duration_seconds");
b.Property<string>("EntryKey") b.Property<string>("EntryKey")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -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;
}
}
}
@@ -3,6 +3,7 @@ using Data.Errors;
using DeepDrftData.Data; using DeepDrftData.Data;
using DeepDrftModels.DTOs; using DeepDrftModels.DTOs;
using DeepDrftModels.Entities; using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Models.Common; using Models.Common;
@@ -157,6 +158,70 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
.Select(g => new { ReleaseId = g.Key, Count = g.Count() }) .Select(g => new { ReleaseId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct); .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, // 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. // signalling the manager to create one. Soft-deleted releases never match.
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync( public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
@@ -211,6 +276,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
target.TrackName = source.TrackName; target.TrackName = source.TrackName;
target.TrackNumber = source.TrackNumber; target.TrackNumber = source.TrackNumber;
target.OriginalFileName = source.OriginalFileName; target.OriginalFileName = source.OriginalFileName;
target.DurationSeconds = source.DurationSeconds;
target.ReleaseId = source.ReleaseId; target.ReleaseId = source.ReleaseId;
} }
} }
+2
View File
@@ -80,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = entity.TrackName, TrackName = entity.TrackName,
OriginalFileName = entity.OriginalFileName, OriginalFileName = entity.OriginalFileName,
TrackNumber = entity.TrackNumber, TrackNumber = entity.TrackNumber,
DurationSeconds = entity.DurationSeconds,
ReleaseId = entity.ReleaseId, ReleaseId = entity.ReleaseId,
Release = entity.Release is null ? null : Convert(entity.Release) Release = entity.Release is null ? null : Convert(entity.Release)
}; };
@@ -96,6 +97,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
TrackName = model.TrackName, TrackName = model.TrackName,
OriginalFileName = model.OriginalFileName, OriginalFileName = model.OriginalFileName,
TrackNumber = model.TrackNumber, TrackNumber = model.TrackNumber,
DurationSeconds = model.DurationSeconds,
ReleaseId = model.ReleaseId ReleaseId = model.ReleaseId
}; };
} }
+55
View File
@@ -236,6 +236,61 @@ public class TrackManager
} }
} }
public async Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default)
{
try
{
var stats = await Repository.GetHomeStatsAsync(cancellationToken);
return ResultContainer<HomeStatsDto>.CreatePassResult(stats);
}
catch (Exception e)
{
return ResultContainer<HomeStatsDto>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default)
{
try
{
var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken);
return ResultContainer<List<TrackDto>>.CreatePassResult(
entities.Select(TrackConverter.Convert).ToList());
}
catch (Exception e)
{
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken);
return ResultContainer<int>.CreatePassResult(updated);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
{
try
{
var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken);
if (affected == 0)
return ResultContainer<int>.CreateFailResult($"Duration write matched no rows for track {id}.");
return ResultContainer<int>.CreatePassResult(affected);
}
catch (Exception e)
{
return ResultContainer<int>.CreateFailResult(e.Message);
}
}
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack) public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
{ {
try try
+1 -2
View File
@@ -9,8 +9,7 @@
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8"> <MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
<MudImage Fluid="true" Src="img/cms-hero.png" Alt="Deep Drft" /> <MudImage Fluid="true" Src="img/cms-hero.webp" Alt="Deep Drft" />
<MudText Typo="Typo.h2" Align="Align.Center">Deep Drft</MudText>
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary"> <MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
Catalogue Management Catalogue Management
</MudText> </MudText>
@@ -59,14 +59,19 @@
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@ single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
<MudGrid> <MudGrid>
<MudItem xs="12" md="5"> <MudItem xs="12" md="5">
@* 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" <BatchTrackList Tracks="_tracks"
@bind-SelectedIndex="_selectedIndex" @bind-SelectedIndex="_selectedIndex"
Disabled="_saving" Disabled="_saving"
AllowNewTracks="@(_medium == ReleaseMedium.Cut)" AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)"
OnWavFilesSelected="HandleWavFilesSelected" OnWavFilesSelected="HandleWavFilesSelected"
OnMoveUp="MoveUp" OnMoveUp="MoveUp"
OnMoveDown="MoveDown" OnMoveDown="MoveDown"
OnRemove="RemoveRow" /> OnRemove="RemoveRow"
OnReplaceFileSelected="HandleReplaceFileSelected" />
</MudItem> </MudItem>
<MudItem xs="12" md="7"> <MudItem xs="12" md="7">
@@ -109,8 +114,8 @@
</MudContainer> </MudContainer>
@code { @code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload. // ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
private const long MaxUploadBytes = 1_073_741_824L; private const long MaxUploadBytes = 2_000_000_000L;
// Release-title addressing (Album-mode batch Edit): loads the whole release by title. // Release-title addressing (Album-mode batch Edit): loads the whole release by title.
[Parameter] public string AlbumName { get; set; } = string.Empty; [Parameter] public string AlbumName { get; set; } = string.Empty;
@@ -322,6 +327,85 @@
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1; 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() private void RemoveCover()
{ {
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling // Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
@@ -21,7 +21,7 @@ else
{ {
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false"> <MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText> <MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText> <MudText Typo="Typo.caption" Color="Color.Default">Use the Replace audio action in the list to swap this track's audio.</MudText>
</MudField> </MudField>
} }
else else
@@ -34,12 +34,37 @@
Disabled="@(index == Tracks.Count - 1 || Disabled)" Disabled="@(index == Tracks.Count - 1 || Disabled)"
OnClick="@(() => OnMoveDown.InvokeAsync(index))" OnClick="@(() => OnMoveDown.InvokeAsync(index))"
aria-label="Move track down" /> aria-label="Move track down" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" @* Replace audio: existing (persisted) rows only. New rows still pick their WAV
Color="Color.Error" via the file input above, so a replace control there would be redundant. A
Size="Size.Small" native <label for> drives a per-row hidden InputFile — clicking the icon
Disabled="@Disabled" opens that row's picker with zero JS (no eval, no programmatic .click()). *@
OnClick="@(() => OnRemove.InvokeAsync(index))" @if (row.Id.HasValue)
aria-label="Remove track" /> {
<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> </MudStack>
@if (row.Status == BatchRowStatus.Uploading) @if (row.Status == BatchRowStatus.Uploading)
{ {
@@ -60,11 +85,28 @@
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; } [Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public bool Disabled { get; set; } [Parameter] public bool Disabled { get; set; }
[Parameter] public bool AllowNewTracks { get; set; } = true; [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<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
[Parameter] public EventCallback<int> OnMoveUp { get; set; } [Parameter] public EventCallback<int> OnMoveUp { get; set; }
[Parameter] public EventCallback<int> OnMoveDown { get; set; } [Parameter] public EventCallback<int> OnMoveDown { get; set; }
[Parameter] public EventCallback<int> OnRemove { 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 const int MaxFilesPerPick = 50;
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index); private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
@@ -108,9 +108,9 @@
</MudContainer> </MudContainer>
@code { @code {
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the // ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
// streaming path means the limit caps the request, not in-memory buffering. // streaming path means the limit caps the request, not in-memory buffering.
private const long MaxUploadBytes = 1_073_741_824L; private const long MaxUploadBytes = 2_000_000_000L;
private List<BatchRowModel> _tracks = new(); private List<BatchRowModel> _tracks = new();
private int _selectedIndex = -1; private int _selectedIndex = -1;
+174 -77
View File
@@ -28,11 +28,11 @@ public class CmsTrackService : ICmsTrackService
private const int DefaultIdleTimeoutSeconds = 90; private const int DefaultIdleTimeoutSeconds = 90;
// Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor // Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor
// decode → vault write → SQL persist. For a several-hundred-MB WAV this can take many minutes. // decode → vault write → SQL persist. For a multi-GB WAV this can exceed 10 minutes.
// The idle heartbeat goes silent after the last byte, so a separate, larger deadline governs the // The idle heartbeat goes silent after the last byte, so a separate, larger deadline governs the
// response-wait phase so a fully-uploaded file is never killed mid-persist. // response-wait phase so a fully-uploaded file is never killed mid-persist.
// Operator-tunable via Upload:ResponseTimeoutSeconds. // Operator-tunable via Upload:ResponseTimeoutSeconds.
private const int DefaultResponseTimeoutSeconds = 600; // 10 minutes private const int DefaultResponseTimeoutSeconds = 1200; // 20 minutes
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<CmsTrackService> _logger; private readonly ILogger<CmsTrackService> _logger;
@@ -71,54 +71,11 @@ public class CmsTrackService : ICmsTrackService
IProgress<long>? progress = null, IProgress<long>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// Two-phase cancellation for the upload send: // Build the WAV part once; the two-phase send helper owns the cancellation plumbing.
// using var phase = new UploadPhase(this, ct);
// BODY-STREAMING phase (while bytes are on the wire): var wavContent = phase.WrapContent(wavStream, contentLength, contentType, progress);
// idleCts fires if no progress tick arrives within the idle window. Each
// ProgressStreamContent chunk resets CancelAfter(idle), so a slow-but-moving
// upload never trips it; a genuinely stalled socket does.
//
// RESPONSE-WAIT phase (after the last byte, while the server persists):
// The idle heartbeat goes silent once the body is fully sent. responseCts is
// armed at that moment with a larger budget so a fully-uploaded file is never
// killed mid-persist. idleCts is simultaneously disarmed (CancelAfter(Infinite))
// so it cannot misfire during the response-wait.
//
// sendCts links both so either deadline — plus the caller's ct — cancels the send.
using var idleCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
idleCts.CancelAfter(_uploadIdleTimeout);
// responseCts starts disarmed; the body-complete callback below arms it.
using var responseCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Umbrella token passed to SendAsync — either phase token (or the caller) can cancel.
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(idleCts.Token, responseCts.Token);
// Rebuild the multipart container so the boundary is owned by HttpClient and the
// caller-supplied stream (already buffered by the SignalR upload) is the source.
using var multipart = new MultipartFormDataContent(); using var multipart = new MultipartFormDataContent();
var wavContent = new ProgressStreamContent(
wavStream,
contentLength,
written =>
{
// One mechanism, three consumers: advance the UI meter, reset the idle heartbeat,
// and on body-complete transition to the response-wait budget.
progress?.Report(written);
if (written < contentLength)
{
// Body still in flight — keep the idle heartbeat alive.
idleCts.CancelAfter(_uploadIdleTimeout);
}
else
{
// Last byte on the wire. Disarm the idle timer and start the response budget.
idleCts.CancelAfter(Timeout.InfiniteTimeSpan);
responseCts.CancelAfter(_uploadResponseTimeout);
}
});
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
multipart.Add(wavContent, "audioFile", fileName); multipart.Add(wavContent, "audioFile", fileName);
multipart.Add(new StringContent(trackName), "trackName"); multipart.Add(new StringContent(trackName), "trackName");
multipart.Add(new StringContent(artist), "artist"); multipart.Add(new StringContent(artist), "artist");
@@ -135,36 +92,10 @@ public class CmsTrackService : ICmsTrackService
// for an unrecognised value). Authoritative only when this upload creates the release. // for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium"); multipart.Add(new StringContent(medium.ToString()), "medium");
// Use the dedicated upload client (InfiniteTimeSpan) so the two-phase CTS logic above is the var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
// sole timeout authority. Non-upload operations use the bounded "DeepDrft.Content.Cms" client. if (send.Response is not { } response)
var client = _httpClientFactory.CreateClient(UploadClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
HttpResponseMessage response;
try
{ {
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, sendCts.Token); return ResultContainer<TrackDto>.CreateFailResult(send.FailureMessage!);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Either idle window (body-streaming stall) or response-wait budget (server persist too slow).
if (idleCts.IsCancellationRequested)
{
_logger.LogWarning("Upload of {TrackName} stalled — no progress for {IdleSeconds}s; aborting.",
trackName, _uploadIdleTimeout.TotalSeconds);
return ResultContainer<TrackDto>.CreateFailResult(
$"Upload stalled — no data transferred for {_uploadIdleTimeout.TotalSeconds:0}s. Please retry.");
}
// responseCts fired: body reached the server but persist timed out.
_logger.LogWarning("Upload of {TrackName} timed out waiting for server response after {ResponseSeconds}s.",
trackName, _uploadResponseTimeout.TotalSeconds);
return ResultContainer<TrackDto>.CreateFailResult(
$"Upload timed out waiting for the server to respond after {_uploadResponseTimeout.TotalSeconds:0}s. Please retry.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName);
return ResultContainer<TrackDto>.CreateFailResult("Content API is unreachable.");
} }
using (response) using (response)
@@ -208,6 +139,172 @@ public class CmsTrackService : ICmsTrackService
} }
} }
public async Task<Result> ReplaceTrackAudioAsync(
long id,
Stream wavStream,
long contentLength,
string fileName,
string contentType,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
// Same two-phase send plumbing as UploadTrackAsync — a WAV replace is an equally large body.
// The request carries only the audio part; the server resolves the track by route id and
// preserves its metadata, so no metadata fields ride along.
using var phase = new UploadPhase(this, ct);
var wavContent = phase.WrapContent(wavStream, contentLength, contentType, progress);
using var multipart = new MultipartFormDataContent();
multipart.Add(wavContent, "audioFile", fileName);
var send = await phase.SendAsync($"api/track/{id}/replace-audio", multipart, $"replace of track {id}");
if (send.Response is not { } response)
{
return Result.CreateFailResult(send.FailureMessage!);
}
using (response)
{
if (response.IsSuccessStatusCode)
{
return Result.CreatePassResult();
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result.CreateFailResult("Track not found.");
}
var body = await response.Content.ReadAsStringAsync(ct);
var statusCode = (int)response.StatusCode;
if (statusCode >= 500)
{
_logger.LogError("Content API returned {Status} for replace of track {TrackId}: {Body}", statusCode, id, body);
return Result.CreateFailResult("Replace failed on the content server. Please try again.");
}
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
_logger.LogWarning("Content API rejected replace of track {TrackId}: {Status} {Body}", id, statusCode, body);
return Result.CreateFailResult(
string.IsNullOrWhiteSpace(body) ? $"Replace rejected ({statusCode})." : body);
}
}
/// <summary>
/// Owns the two-phase cancellation for a large-body multipart send (the original upload and the
/// audio replace share it identically):
///
/// BODY-STREAMING phase (while bytes are on the wire): the idle CTS fires if no progress tick
/// arrives within the idle window. Each <see cref="ProgressStreamContent"/> chunk resets it, so a
/// slow-but-moving body never trips it; a genuinely stalled socket does.
///
/// RESPONSE-WAIT phase (after the last byte, while the server persists): the idle heartbeat goes
/// silent, so a separate, larger budget is armed at body-complete and the idle timer is disarmed,
/// guaranteeing a fully-sent body is never killed mid-persist.
///
/// The send CTS links both phase tokens plus the caller's token. Single-sourcing this here keeps
/// the idle/response-wait behaviour identical across every large-body call.
/// </summary>
private sealed class UploadPhase : IDisposable
{
private readonly CmsTrackService _owner;
private readonly CancellationToken _callerToken;
private readonly CancellationTokenSource _idleCts;
private readonly CancellationTokenSource _responseCts;
private readonly CancellationTokenSource _sendCts;
public UploadPhase(CmsTrackService owner, CancellationToken callerToken)
{
_owner = owner;
_callerToken = callerToken;
_idleCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken);
_idleCts.CancelAfter(owner._uploadIdleTimeout);
// responseCts starts disarmed; the body-complete callback arms it.
_responseCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken);
_sendCts = CancellationTokenSource.CreateLinkedTokenSource(_idleCts.Token, _responseCts.Token);
}
public ProgressStreamContent WrapContent(
Stream wavStream, long contentLength, string contentType, IProgress<long>? progress)
{
var content = new ProgressStreamContent(
wavStream,
contentLength,
written =>
{
// One mechanism, three consumers: advance the UI meter, reset the idle heartbeat,
// and on body-complete transition to the response-wait budget.
progress?.Report(written);
if (written < contentLength)
{
_idleCts.CancelAfter(_owner._uploadIdleTimeout);
}
else
{
// Last byte on the wire. Disarm the idle timer and start the response budget.
_idleCts.CancelAfter(Timeout.InfiniteTimeSpan);
_responseCts.CancelAfter(_owner._uploadResponseTimeout);
}
});
content.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
return content;
}
public async Task<LargeBodySendResult> SendAsync(
string path, HttpContent content, string operationLabel)
{
// Dedicated upload client (InfiniteTimeSpan) so the two-phase CTS logic is the sole timeout
// authority. Non-upload operations use the bounded "DeepDrft.Content.Cms" client.
var client = _owner._httpClientFactory.CreateClient(UploadClientName);
using var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = content };
try
{
var response = await client.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead, _sendCts.Token);
return LargeBodySendResult.Ok(response);
}
catch (OperationCanceledException) when (!_callerToken.IsCancellationRequested)
{
if (_idleCts.IsCancellationRequested)
{
_owner._logger.LogWarning("{Operation} stalled — no progress for {IdleSeconds}s; aborting.",
operationLabel, _owner._uploadIdleTimeout.TotalSeconds);
return LargeBodySendResult.Fail(
$"{operationLabel} stalled — no data transferred for {_owner._uploadIdleTimeout.TotalSeconds:0}s. Please retry.");
}
_owner._logger.LogWarning("{Operation} timed out waiting for server response after {ResponseSeconds}s.",
operationLabel, _owner._uploadResponseTimeout.TotalSeconds);
return LargeBodySendResult.Fail(
$"{operationLabel} timed out waiting for the server to respond after {_owner._uploadResponseTimeout.TotalSeconds:0}s. Please retry.");
}
catch (Exception ex)
{
_owner._logger.LogError(ex, "Content API call failed for {Operation}", operationLabel);
return LargeBodySendResult.Fail("Content API is unreachable.");
}
}
public void Dispose()
{
_sendCts.Dispose();
_responseCts.Dispose();
_idleCts.Dispose();
}
}
// Outcome of a two-phase send: either a live response the caller must dispose, or a user-facing
// failure message. Exactly one is non-null.
private readonly struct LargeBodySendResult
{
public HttpResponseMessage? Response { get; private init; }
public string? FailureMessage { get; private init; }
public static LargeBodySendResult Ok(HttpResponseMessage response) => new() { Response = response };
public static LargeBodySendResult Fail(string message) => new() { FailureMessage = message };
}
public async Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default) public async Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default)
{ {
var client = _httpClientFactory.CreateClient(ContentCmsClientName); var client = _httpClientFactory.CreateClient(ContentCmsClientName);
@@ -51,6 +51,24 @@ public interface ICmsTrackService
/// </summary> /// </summary>
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default); Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
/// <summary>
/// Replace an existing track's audio via <c>POST api/track/{id}/replace-audio</c>. Swaps only the
/// vault bytes and regenerates the track's waveform data server-side; the track id, vault key,
/// release membership, position, and metadata are preserved. Uses the dedicated upload client and
/// the same two-phase (idle / response-wait) cancellation as <see cref="UploadTrackAsync"/>, since
/// a WAV replace is a large-body upload. <paramref name="contentLength"/> sets Content-Length and is
/// the denominator for <paramref name="progress"/>; each progress tick resets the idle heartbeat.
/// Maps a 404 to a "Track not found." failure.
/// </summary>
Task<Result> ReplaceTrackAudioAsync(
long id,
Stream wavStream,
long contentLength,
string fileName,
string contentType,
IProgress<long>? progress = null,
CancellationToken ct = default);
/// <summary> /// <summary>
/// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release /// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release
/// has no live tracks and needs to be removed from the albums browser. /// has no live tracks and needs to be removed from the albums browser.
+4
View File
@@ -8,5 +8,9 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"ForwardedHeaders": { "ForwardedHeaders": {
"DisableHttpsRedirection": false "DisableHttpsRedirection": false
},
"Upload": {
"IdleTimeoutSeconds": 90,
"ResponseTimeoutSeconds": 1200
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+16 -1
View File
@@ -15,7 +15,8 @@ DeepDrftModels/
├── Entities/ ├── Entities/
│ └── TrackEntity.cs # Database entity for tracks │ └── TrackEntity.cs # Database entity for tracks
├── DTOs/ ├── DTOs/
── TrackDto.cs # DTO mirror of TrackEntity ── TrackDto.cs # DTO mirror of TrackEntity
│ └── HomeStatsDto.cs # Aggregate figures for the public home hero stat row
├── Models/ ├── Models/
│ ├── PagingParameters.cs # Pagination configuration (base + generic) │ ├── PagingParameters.cs # Pagination configuration (base + generic)
│ └── PagedResult.cs # Paginated result wrapper │ └── PagedResult.cs # Paginated result wrapper
@@ -35,6 +36,7 @@ public string? Album { get; set; } // Optional album (max 200)
public string? Genre { get; set; } // Optional genre (max 100) public string? Genre { get; set; } // Optional genre (max 100)
public DateOnly? ReleaseDate { get; set; } // Optional release date public DateOnly? ReleaseDate { get; set; } // Optional release date
public string? ImagePath { get; set; } // Optional image URL (max 500) public string? ImagePath { get; set; } // Optional image URL (max 500)
public double? DurationSeconds { get; set; } // Audio runtime in seconds (nullable; populated at upload, backfilled for older rows)
``` ```
**No `MediaPath` field exists.** That was a legacy name. The field is `EntryKey`. **No `MediaPath` field exists.** That was a legacy name. The field is `EntryKey`.
@@ -47,6 +49,19 @@ Convention: required reference fields use `required` modifier; optional referenc
Mirrors `TrackEntity` structure (identical fields, same nullability). Used where DTO/entity separation is needed for serialisation. In practice, both flow over the wire today, but the separation is available if APIs need to diverge (e.g., hide `Id` in responses). Mirrors `TrackEntity` structure (identical fields, same nullability). Used where DTO/entity separation is needed for serialisation. In practice, both flow over the wire today, but the separation is available if APIs need to diverge (e.g., hide `Id` in responses).
## HomeStatsDto
Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A single round-trip returns everything the three cards need. Fields:
- `CutTrackCount` (int): total non-deleted tracks on Cut-medium releases.
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (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). Rendered as `hh:mm` by `RuntimeFormat` on the client.
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's `PartialCount + SampledCount + CompleteCount`, all-time (Phase 16 §5). Zero until the play-telemetry migration is applied; that is expected, not an error. The Plays card's primary odometer figure.
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` values across all play events, all-time (Phase 16 §3 / D7). Zero until the migration is applied. The Plays card's secondary line ("N listeners").
`CutReleaseTypeCount` is a nested type (`ReleaseType`, `Count` int) defined in the same file.
## Pagination system ## Pagination system
### PagingParameters (base) ### PagingParameters (base)
+51
View File
@@ -0,0 +1,51 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// 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. The track-domain counts exclude
/// soft-deleted rows; the play-domain figures (Phase 16) come from the event domain.
/// </summary>
public class HomeStatsDto
{
/// <summary>Total non-deleted tracks whose release is the Cut medium. The Studio Cuts card's primary figure.</summary>
public int CutTrackCount { get; set; }
/// <summary>
/// Per-ReleaseType counts of non-deleted Cut releases. Only types with at least one release are
/// present — a zero-count type is absent from the list (the card suppresses it). The Studio Cuts
/// card's secondary breakdown.
/// </summary>
public List<CutReleaseTypeCount> CutReleaseTypeCounts { get; set; } = [];
/// <summary>Total non-deleted releases of the Mix medium. The Mixes card's primary figure ("N Sets").</summary>
public int MixReleaseCount { get; set; }
/// <summary>
/// Sum of DurationSeconds across all non-deleted tracks on Mix releases. Tracks with a null
/// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm.
/// </summary>
public double MixRuntimeSeconds { get; set; }
/// <summary>
/// Site-wide total plays across all tracks — the sum of every play_counter's bucket columns
/// (partial + sampled + complete), all-time (Phase 16 §5). The Plays card's primary odometer figure.
/// Reads zero until the play-telemetry migration is applied; that is expected, not an error.
/// </summary>
public long TotalPlays { get; set; }
/// <summary>
/// Site-wide distinct anonymous listeners — distinct non-null anon_id across all play events,
/// all-time (Phase 16 §3 / D7). The Plays card's secondary line ("N listeners"). Over-counts by
/// design (one token per browser-install, honestly labelled "listeners").
/// </summary>
public int UniqueListeners { get; set; }
}
/// <summary>One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it.</summary>
public class CutReleaseTypeCount
{
public ReleaseType ReleaseType { get; set; }
public int Count { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire payload for <c>POST api/event/play</c> (Phase 16 §2.2 / §4.3). The client sends only what it
/// cheaply knows — the track key and the client-computed completion bucket; the server resolves the
/// release. No duration or raw position is transmitted (a privacy plus — only a coarse bucket leaves
/// the browser). <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
/// </summary>
public class PlayEventDto
{
public string? TrackEntryKey { get; set; }
public PlayBucket Bucket { get; set; }
public string? AnonId { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.DTOs;
/// <summary>
/// Wire payload for <c>POST api/event/share</c> (Phase 16 §2.2 / §4.3). The popover knows the target
/// and channel at the point of the action, so the payload is self-describing — no server-side resolution.
/// <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
/// </summary>
public class ShareEventDto
{
public ShareTargetType TargetType { get; set; }
public string? TargetKey { get; set; }
public ShareChannel Channel { get; set; }
public string? AnonId { get; set; }
}
+1
View File
@@ -16,6 +16,7 @@ public class TrackDto : BaseModel
public string TrackName { get; set; } = string.Empty; public string TrackName { get; set; } = string.Empty;
public string? OriginalFileName { get; set; } public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1; public int TrackNumber { get; set; } = 1;
public double? DurationSeconds { get; set; }
public long? ReleaseId { get; set; } public long? ReleaseId { get; set; }
public ReleaseDto? Release { get; set; } public ReleaseDto? Release { get; set; }
} }
+28
View File
@@ -0,0 +1,28 @@
namespace DeepDrftModels.Entities;
/// <summary>
/// Incremental rollup of play counts per track (Phase 16 §4.1 / D6). One row per track, bumped inside
/// the same transaction that appends the <see cref="PlayEvent"/> — no background aggregation job. The
/// home card and per-target reads sum these instead of <c>COUNT(*)</c>-ing the event log on every
/// landing. Release totals are <em>derived</em> (D4) by summing the counters of the release's tracks,
/// so there is no separate release-counter row — this keeps the rollup normalized at one row per track.
/// </summary>
public class PlayCounter
{
public long Id { get; set; }
/// <summary>The track these counts belong to (SQL id). Unique — one counter row per track.</summary>
public long TrackId { get; set; }
/// <summary>Count of plays that ended in the <c>Partial</c> bucket (&lt; 30%).</summary>
public long PartialCount { get; set; }
/// <summary>Count of plays that ended in the <c>Sampled</c> bucket (30%80%).</summary>
public long SampledCount { get; set; }
/// <summary>Count of plays that ended in the <c>Complete</c> bucket (&gt; 80%).</summary>
public long CompleteCount { get; set; }
/// <summary>Total plays for the track — the sum of the three bucket counts (headline figure).</summary>
public long TotalPlays => PartialCount + SampledCount + CompleteCount;
}
+36
View File
@@ -0,0 +1,36 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.Entities;
/// <summary>
/// Append-only log row for one recorded play (Phase 16 §4.2). Written once at session close, after the
/// engagement floor is crossed; never updated or deleted. Deliberately NOT a <c>BaseEntity</c>: events
/// have no soft-delete lifecycle, no <c>UpdatedAt</c> — they are immutable facts. The release link is
/// resolved server-side from the track key at write time (§2.3 / D4) and stored here so release-total
/// plays are a cheap sum over this column.
/// </summary>
public class PlayEvent
{
public long Id { get; set; }
/// <summary>The played track's vault entry key (the only target the client sends).</summary>
public required string TrackEntryKey { get; set; }
/// <summary>
/// The owning release's SQL id, resolved from <see cref="TrackEntryKey"/> at write time. Null when
/// the track is loose (no release) or the key did not resolve to a live track at write time.
/// </summary>
public long? ReleaseId { get; set; }
/// <summary>The completion bucket computed client-side from the high-water fraction (§1a / D1).</summary>
public PlayBucket Bucket { get; set; }
/// <summary>
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; nothing writes it in wave
/// 16.1 — the client sends none and the column stays NULL. Wave 16.3 lights it up for the
/// distinct-listener count.
/// </summary>
public string? AnonId { get; set; }
public DateTime CreatedAt { get; set; }
}
+34
View File
@@ -0,0 +1,34 @@
using DeepDrftModels.Enums;
namespace DeepDrftModels.Entities;
/// <summary>
/// Append-only log row for one recorded share (Phase 16 §4.2). Written once per share action that
/// survives the per-(target,channel) client debounce; never updated or deleted. Like <see cref="PlayEvent"/>
/// it is deliberately NOT a <c>BaseEntity</c> — an immutable fact with no soft-delete lifecycle. Shares
/// carry their target directly (the popover knows track vs. release), so no server-side resolution step.
/// </summary>
public class ShareEvent
{
public long Id { get; set; }
/// <summary>Whether the share targets a track or a release.</summary>
public ShareTargetType TargetType { get; set; }
/// <summary>
/// The shared target's key: a track's vault <c>EntryKey</c> or a release's public <c>EntryKey</c>,
/// selected by <see cref="TargetType"/>. Stored as the opaque key, not resolved to a SQL id — the
/// share metric is a simple per-target tally and needs no join in wave 16.1.
/// </summary>
public required string TargetKey { get; set; }
/// <summary>The channel the share was performed through (link vs. embed).</summary>
public ShareChannel Channel { get; set; }
/// <summary>
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; unused in wave 16.1.
/// </summary>
public string? AnonId { get; set; }
public DateTime CreatedAt { get; set; }
}
+4
View File
@@ -15,6 +15,10 @@ public class TrackEntity : BaseEntity, IEntity
public required string TrackName { get; set; } public required string TrackName { get; set; }
public string? OriginalFileName { get; set; } public string? OriginalFileName { get; set; }
public int TrackNumber { get; set; } = 1; public int TrackNumber { get; set; } = 1;
// Audio runtime in seconds, extracted by the processor at upload (AudioBinary.Duration) and
// persisted here so aggregate queries (e.g. total mix runtime) read it from SQL rather than the
// vault. Nullable: rows that predate this column are valid until the one-time backfill populates them.
public double? DurationSeconds { get; set; }
public long? ReleaseId { get; set; } public long? ReleaseId { get; set; }
public ReleaseEntity? Release { get; set; } public ReleaseEntity? Release { get; set; }
} }
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace DeepDrftModels.Enums;
/// <summary>
/// Completion bucket for a recorded play (Phase 16 §1a / D1). The three buckets are exhaustive and
/// non-overlapping, classified by the high-water playback fraction reached before the session closed:
/// <c>Partial</c> [0, 30%), <c>Sampled</c> [30%, 80%], <c>Complete</c> (80%, 100%]. The headline
/// "Plays" figure is the sum of all three — every started listen that crosses the engagement floor
/// is a play; the buckets are the texture beneath it.
///
/// Serialized as its string name on the wire — the converter on the type makes the
/// client to proxy to API JSON contract string-based regardless of host serializer config.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<PlayBucket>))]
public enum PlayBucket
{
/// <summary>Reached &lt; 30% of duration — a skip or a brief partial listen (still past the floor).</summary>
Partial,
/// <summary>Reached 30%80% of duration — a real listen that was neither a skip nor a finish.</summary>
Sampled,
/// <summary>Reached &gt; 80% of duration — effectively a finished listen.</summary>
Complete
}
+33
View File
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace DeepDrftModels.Enums;
/// <summary>
/// The channel a share was performed through (Phase 16 §1b). Today both originate from
/// <c>SharePopover</c>'s clipboard actions; a future native/Web-Share button would add a channel
/// without reshaping the metric. Serialized as its string name on the wire.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ShareChannel>))]
public enum ShareChannel
{
/// <summary>Copy-link — the canonical track or release URL placed on the clipboard.</summary>
Link,
/// <summary>Copy-embed — the <c>&lt;iframe&gt;</c> snippet for the single-track FramePlayer.</summary>
Embed
}
/// <summary>
/// What a share targets (Phase 16 §1b). Tracks and releases are both shareable; the popover knows
/// which it is at the point of the action, so no server-side resolution is needed for shares.
/// Serialized as its string name on the wire.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ShareTargetType>))]
public enum ShareTargetType
{
/// <summary>The share targets a single track, addressed by its vault <c>EntryKey</c>.</summary>
Track,
/// <summary>The share targets a release, addressed by its public <c>EntryKey</c>.</summary>
Release
}
+19 -2
View File
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure ## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist). - `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list). - `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Controls/`: Reusable components. - `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter. - `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
@@ -19,7 +19,8 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`. - `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close). - `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). - `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. - `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state. - `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure. - `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0100%), with fixed three-zone gradient (green 060%, yellow 6085%, orange 85100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback. - `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
@@ -30,10 +31,17 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`). - `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer. - `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`. - `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
- `NowPlaying.razor`: Owns the home hero's right-side panel (`.now-playing-panel` — the outer wrapper formerly called `.hero-right` in `Home.razor`). Mounts `<WaveformVisualizer Fill="true">` as a full-bleed background inside `.np-visualizer-bg`, `<WaveformVisualizerControlPopover>` in `.np-visualizer-controls` (top-right corner), the three pulsing `.circle-deco` rings, and the content layer (hosts `<NowPlayingCard>` + `<NowPlayingStats>`). `Home.razor`'s `MudItem` renders `<NowPlaying />` directly with no wrapper. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is `IsFixed` (the provider's own re-render does not reach `NowPlaying`), so the subscription is the only way to re-render and re-propagate `ReleaseEntryKey`/`TrackId`/`TrackEntryKey` into `<WaveformVisualizer>` when the playing track changes. - `NowPlaying.razor`: Owns the home hero's right-side panel (`.now-playing-panel` — the outer wrapper formerly called `.hero-right` in `Home.razor`). Mounts `<WaveformVisualizer Fill="true">` as a full-bleed background inside `.np-visualizer-bg`, `<WaveformVisualizerControlPopover>` in `.np-visualizer-controls` (top-right corner), the three pulsing `.circle-deco` rings, and the content layer (hosts `<NowPlayingCard>` + `<NowPlayingStats>`). `Home.razor`'s `MudItem` renders `<NowPlaying />` directly with no wrapper. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is `IsFixed` (the provider's own re-render does not reach `NowPlaying`), so the subscription is the only way to re-render and re-propagate `ReleaseEntryKey`/`TrackId`/`TrackEntryKey` into `<WaveformVisualizer>` when the playing track changes.
- `QueueList.razor`: Shared presentational queue-list component (Phase 17 wave 17.1). Renders `Items` as an ordered list with the current track marked; `Editable` flag gates drag-reorder handles (drag handle icon + `MudDropContainer`/`MudDropZone` for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (`Editable && !isCurrent`) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain `<div>` — the read-only state for the embed's fixed-order shared queue. Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump`; the component calls no `IQueueService` method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the `Editable` flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
- `QueueOverlay.razor`: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode. Opened/closed by the Queue toggle button in `PlayerTransportZone` (shown only when `!Fixed && Items.Count > 0`; `QueueMusic` glyph, active state when open).
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. - `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
- `Helpers/`: Utilities and mapper functions. - `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple. - `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)``<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)``<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
- `Services/`: Audio player + dark-mode services. - `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI. - `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume). - `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -41,13 +49,22 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks. - `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write). - Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load. - `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
- `Clients/`: HTTP API clients (both target DeepDrftAPI). - `Clients/`: HTTP API clients (both target DeepDrftAPI).
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope). - `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer. - `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)``Stream` with optional Range header support for seek-beyond-buffer.
- `StatsClient`: Home stats API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Single method `GetHomeStats()``ApiResult<HomeStatsDto>` (calls `GET api/stats/home`; response is a bare DTO, no `ApiResultDto` envelope). Registered scoped; consumed via `IStatsDataService`.
- `Services/ITrackDataService`: Contract used by the visualizer bridge and other consumers. Includes `GetTrackWaveform(entryKey)` → high-res `WaveformProfileDto` (calls `GET api/track/{entryKey}/waveform/high-res`); used by `WaveformVisualizer` to re-fetch the datum on track change. - `Services/ITrackDataService`: Contract used by the visualizer bridge and other consumers. Includes `GetTrackWaveform(entryKey)` → high-res `WaveformProfileDto` (calls `GET api/track/{entryKey}/waveform/high-res`); used by `WaveformVisualizer` to re-fetch the datum on track change.
- `Services/IStatsDataService` / `StatsClientDataService`: Home-stats read abstraction. `IStatsDataService.GetHomeStats()``ApiResult<HomeStatsDto>`. `StatsClientDataService` is the single implementation (delegates to `StatsClient`); registered scoped. Components inject `IStatsDataService` so they do not branch on render mode — mirrors `IReleaseDataService`.
- `ViewModels/`: Component state. - `ViewModels/`: Component state.
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`. - `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`. - `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
- `FramePlayerViewModel`: Scoped. Resolves the ordered track list for a release embed (`FramePlayer?ReleaseEntryKey=…`). `Load(releaseEntryKey)` calls `IReleaseDataService.GetByEntryKey``release.Id``ITrackDataService.GetPage(sortColumn:"TrackNumber", releaseId:…)`, mirroring `CutDetailViewModel.Load` exactly so an embedded release queues the same ordered list the Cut detail page plays. Owns no playback or staging — `FramePlayer.razor` uses the loaded `Tracks` to stage and arm. Registered scoped in `Startup.ConfigureDomainServices`.
- `Common/`: Shared utilities. - `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped. - `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B). - `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
@@ -0,0 +1,39 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
using System.Text.Json;
namespace DeepDrftPublic.Client.Clients;
/// <summary>
/// HTTP client for the public stats read surface. Uses the named <c>"DeepDrft.API"</c> client like
/// <see cref="TrackClient"/> and <see cref="ReleaseClient"/>: on WASM it points at the public host and
/// proxies through <c>StatsProxyController</c>; on SSR prerender it points directly at DeepDrftAPI. The
/// route is an unauthenticated read; the response deserializes as a bare DTO (no ApiResultDto envelope),
/// matching the API's <c>Ok(value)</c> shape.
/// </summary>
public class StatsClient
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
public StatsClient(IHttpClientFactory httpClientFactory)
{
_http = httpClientFactory.CreateClient("DeepDrft.API");
}
public async Task<ApiResult<HomeStatsDto>> GetHomeStats()
{
var response = await _http.GetAsync("api/stats/home");
if (!response.IsSuccessStatusCode)
return ApiResult<HomeStatsDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var stats = JsonSerializer.Deserialize<HomeStatsDto>(json, JsonOptions);
return stats is not null
? ApiResult<HomeStatsDto>.CreatePassResult(stats)
: ApiResult<HomeStatsDto>.CreateFailResult("Failed to deserialize response");
}
}
@@ -0,0 +1,46 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftModels.DTOs
@using DeepDrftPublic.Client.Services
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
<MudTooltip Text="@Tooltip">
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
Color="@Color"
Size="@Size"
Disabled="@(Queue is null || !RendererInfo.IsInteractive)"
OnClick="@AddToQueue" />
</MudTooltip>
@code {
[CascadingParameter] public IQueueService? Queue { get; set; }
/// <summary>Single track to append (track mode). Mutually exclusive with <see cref="ReleaseTracks"/>.</summary>
[Parameter] public TrackDto? Track { get; set; }
/// <summary>Ordered release tracks to append (release mode). Mutually exclusive with <see cref="Track"/>.</summary>
[Parameter] public IReadOnlyList<TrackDto>? ReleaseTracks { get; set; }
[Parameter] public Size Size { get; set; } = Size.Medium;
[Parameter] public Color Color { get; set; } = Color.Secondary;
private string Tooltip => ReleaseTracks is not null ? "Add release to queue" : "Add to queue";
private void AddToQueue()
{
if (Queue is null) return;
if (ReleaseTracks is not null)
{
Queue.EnqueueRange(ReleaseTracks);
}
else if (Track is not null)
{
Queue.Enqueue(Track);
}
}
}
@@ -25,12 +25,15 @@ else
HasPrevious="HasPrevious" HasPrevious="HasPrevious"
SkipNext="@SkipNext" SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious" SkipPrevious="@SkipPrevious"
ShowQueueButton="ShowQueueButton"
QueueOpen="_queueOpen"
QueueToggle="@ToggleQueue"
Class="transport-zone"/> Class="transport-zone"/>
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/> <VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
<div class="meta-zone"> <div class="meta-zone">
<TrackMetaLabel Track="CurrentTrack"/> <TrackMetaLabel Track="CurrentTrack" Fixed="Fixed"/>
</div> </div>
<PlayerSeekZone OnSeekStart="@OnSeekStart" <PlayerSeekZone OnSeekStart="@OnSeekStart"
@@ -56,5 +59,20 @@ else
@ErrorMessage @ErrorMessage
</MudAlert> </MudAlert>
} }
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
the Fixed embed gets its own inline panel in a later wave. *@
@if (ShowQueueButton)
{
<QueueOverlay Visible="_queueOpen"
Items="QueueItems"
CurrentIndex="QueueCurrentIndex"
OnClose="@CloseQueue"
OnClear="@ClearUpcoming"
OnReorder="@OnQueueReorder"
OnRemove="@OnQueueRemove"
OnJump="@OnQueueJump"/>
}
</div> </div>
} }
@@ -19,6 +19,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool _isMinimized = true; private bool _isMinimized = true;
private bool _isSeeking = false; private bool _isSeeking = false;
private double _seekPosition = 0; private double _seekPosition = 0;
private bool _queueOpen = false;
private IStreamingPlayerService? _subscribedService; private IStreamingPlayerService? _subscribedService;
private IQueueService? _subscribedQueue; private IQueueService? _subscribedQueue;
@@ -63,6 +64,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool HasNext => QueueService?.HasNext ?? false; private bool HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false; private bool HasPrevious => QueueService?.HasPrevious ?? false;
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
/// <summary> /// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time. /// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary> /// </summary>
@@ -106,7 +114,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged); private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
private void OnQueueChanged() => InvokeAsync(StateHasChanged); private void OnQueueChanged()
{
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
// mount too, so this keeps state and view consistent.
if (_queueOpen && (QueueService?.Items.Count ?? 0) == 0)
_queueOpen = false;
InvokeAsync(StateHasChanged);
}
private async Task SkipNext() private async Task SkipNext()
{ {
@@ -120,6 +137,27 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await QueueService.Previous(); await QueueService.Previous();
} }
private void ToggleQueue() => _queueOpen = !_queueOpen;
private void CloseQueue() => _queueOpen = false;
// Reorder/remove/clear are interop-free engine mutations (C2/C5): they never re-stream or interrupt
// the playing track. QueueChanged re-renders the bar and the overlay's list.
private void OnQueueReorder((int FromIndex, int ToIndex) move) =>
QueueService?.Move(move.FromIndex, move.ToIndex);
private void OnQueueRemove(int index) => QueueService?.RemoveAt(index);
private void ClearUpcoming() => QueueService?.ClearUpcoming();
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
// touches playback — it streams the chosen track via the player.
private async Task OnQueueJump(int index)
{
if (QueueService == null) return;
await QueueService.PlayRelease(QueueService.Items, index);
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
// The Fixed embed is already in normal flow — no spacer/clip needed. // The Fixed embed is already in normal flow — no spacer/clip needed.
@@ -186,6 +224,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// the first play click — the user gesture the browser requires before audio can start. // the first play click — the user gesture the browser requires before audio can start.
if (IsStaged) if (IsStaged)
{ {
// Release embed: the queue is armed with the whole release. Route the first gesture through
// the queue so it takes over (streams track 0 and auto-advances) rather than streaming the
// staged track in isolation. Single-track embeds leave the queue disarmed and fall through
// to the direct stream below — unchanged.
if (QueueService is { IsArmed: true })
{
await QueueService.Start();
return;
}
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!); await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
return; return;
} }
@@ -250,6 +298,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
_subscribedService = null; _subscribedService = null;
} }
if (_subscribedQueue != null)
{
_subscribedQueue.QueueChanged -= OnQueueChanged;
_subscribedQueue = null;
}
if (_spacerModule is not null) if (_spacerModule is not null)
{ {
try try
@@ -23,7 +23,7 @@
} }
::deep .player-inner-container { ::deep .player-inner-container {
padding: 0.75rem; padding: 0 0.75rem 0.75rem 0.75rem;
} }
/* The visible surface is a MudPaper; scoped CSS only sets geometry + a hairline accent */ /* The visible surface is a MudPaper; scoped CSS only sets geometry + a hairline accent */
@@ -2,7 +2,7 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@if (!Fixed) @if (!Fixed || HasPrevious || HasNext)
{ {
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious" <MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
Color="Color.Primary" Color="Color.Primary"
@@ -14,13 +14,16 @@
Color="Color.Primary" Color="Color.Primary"
Disabled="!CanPlay" Disabled="!CanPlay"
OnToggle="@TogglePlayPause"/> OnToggle="@TogglePlayPause"/>
@if (!Fixed) @if (!Fixed || HasPrevious || HasNext)
{ {
<MudIconButton Icon="@Icons.Material.Filled.SkipNext" <MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Color="Color.Primary" Color="Color.Primary"
Size="Size.Large" Size="Size.Large"
OnClick="@SkipNext" OnClick="@SkipNext"
Disabled="!HasNext"/> Disabled="!HasNext"/>
}
@if (!Fixed)
{
<MudIconButton Icon="@Icons.Material.Filled.Stop" <MudIconButton Icon="@Icons.Material.Filled.Stop"
Color="Color.Primary" Color="Color.Primary"
Size="Size.Large" Size="Size.Large"
@@ -20,5 +20,20 @@
Indeterminate="@(LoadProgress == 0)"/> Indeterminate="@(LoadProgress == 0)"/>
} }
</MudStack> </MudStack>
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
@if (ShowQueueButton)
{
<MudTooltip Text="Queue">
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
Color="Color.Primary"
Size="Size.Medium"
OnClick="QueueToggle"
aria-label="Queue"
aria-expanded="@QueueOpen"
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
</MudTooltip>
}
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/> <TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
</MudStack> </MudStack>
@@ -18,5 +18,15 @@ public partial class PlayerTransportZone : ComponentBase
[Parameter] public bool HasPrevious { get; set; } [Parameter] public bool HasPrevious { get; set; }
[Parameter] public EventCallback SkipNext { get; set; } [Parameter] public EventCallback SkipNext { get; set; }
[Parameter] public EventCallback SkipPrevious { get; set; } [Parameter] public EventCallback SkipPrevious { get; set; }
/// <summary>Whether to render the Queue toggle button. Gated on a non-empty queue by the bar.</summary>
[Parameter] public bool ShowQueueButton { get; set; }
/// <summary>Whether the queue overlay is open. Drives the button's active state.</summary>
[Parameter] public bool QueueOpen { get; set; }
/// <summary>Raised when the Queue button is clicked. The bar toggles the overlay.</summary>
[Parameter] public EventCallback QueueToggle { get; set; }
[Parameter] public string? Class { get; set; } [Parameter] public string? Class { get; set; }
} }
@@ -8,14 +8,26 @@
<div class="track-meta-identity"> <div class="track-meta-identity">
@* Title links to the release's dedicated detail page via the shared resolver (§2): the @* Title links to the release's dedicated detail page via the shared resolver (§2): the
TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no
release is attached there is no medium to resolve, so the title renders unlinked. *@ release is attached there is no medium to resolve, so the title renders unlinked.
When Fixed (embedded iframe), the link opens in a new tab so the iframe keeps playing. *@
@if (Track.Release is not null) @if (Track.Release is not null)
{ {
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;"> @if (Fixed)
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate"> {
@Track.TrackName <a href="@ReleaseRoutes.DetailHref(Track.Release)" target="_blank" rel="noopener noreferrer" style="text-decoration: none;">
</MudText> <MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
</a> @Track.TrackName
</MudText>
</a>
}
else
{
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
@Track.TrackName
</MudText>
</a>
}
} }
else else
{ {
@@ -11,4 +11,5 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
public partial class TrackMetaLabel : ComponentBase public partial class TrackMetaLabel : ComponentBase
{ {
[Parameter] public TrackDto? Track { get; set; } [Parameter] public TrackDto? Track { get; set; }
[Parameter] public bool Fixed { get; set; }
} }
@@ -10,6 +10,9 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
[Inject] public required AudioInteropService AudioInterop { get; set; } [Inject] public required AudioInteropService AudioInterop { get; set; }
[Inject] public required TrackMediaClient TrackMediaClient { get; set; } [Inject] public required TrackMediaClient TrackMediaClient { get; set; }
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; } [Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
[Inject] public required BeaconInterop Beacon { get; set; }
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
private IStreamingPlayerService? _audioPlayerService; private IStreamingPlayerService? _audioPlayerService;
private QueueService? _queueService; private QueueService? _queueService;
@@ -23,7 +26,16 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
// EnsureInitializedAsync — that path is correct because audio contexts // EnsureInitializedAsync — that path is correct because audio contexts
// require a user gesture anyway. Initializing eagerly here causes 4+ // require a user gesture anyway. Initializing eagerly here causes 4+
// SignalR round-trips before any content is stable. // SignalR round-trips before any content is stable.
_audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger); var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
// Phase 16: bind the play-session tracker to the player after construction, the same way the
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
// constructor would force the provider to over-resolve. The tracker owns the floor/bucket logic
// and emits via the injected sink (the beacon in production); the beacon also drives the
// page-unload close so a mid-play tab-close still records the listen. Attached on the concrete
// type before it is exposed through the IStreamingPlayerService field.
player.AttachTracker(new PlayTracker(PlayEventSink), Beacon);
_audioPlayerService = player;
// Provider is the SOLE owner of OnStateChanged. When the service fires, // Provider is the SOLE owner of OnStateChanged. When the service fires,
// the provider re-renders, which cascades to its children automatically. // the provider re-renders, which cascades to its children automatically.
@@ -39,6 +51,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
_queueService.Attach(_audioPlayerService); _queueService.Attach(_audioPlayerService);
} }
/// <summary>
/// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the
/// first render, because the localStorage read is JS interop — not available during prerender. By the
/// time any play session closes and the sink reads <c>AnonId.Current</c>, the cache is populated; a
/// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The
/// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every
/// page the player can play from.
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await AnonId.EnsureLoadedAsync();
}
/// <summary> /// <summary>
/// Dispose the player on unmount so the JS setInterval driving progress /// Dispose the player on unmount so the JS setInterval driving progress
/// callbacks no longer holds a DotNetObjectReference into a destroyed /// callbacks no longer holds a DotNetObjectReference into a destroyed
@@ -31,7 +31,8 @@
<div class="now-playing-content"> <div class="now-playing-content">
<NowPlayingCard /> <NowPlayingCard />
@* Stat row - hard-coded for now. TODO Phase 2: wire to real track count / identity model. *@ @* Stat row — live aggregate figures (Cut track count + type breakdown, Mix sets + runtime);
the Plays card is a static placeholder pending real play tracking. *@
<NowPlayingStats /> <NowPlayingStats />
</div> </div>
</div> </div>
@@ -1,14 +1,97 @@
@using DeepDrftModels.DTOs
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Helpers
@using DeepDrftPublic.Client.Services
@implements IDisposable
<div class="hero-stat-row"> <div class="hero-stat-row">
@* Studio Cuts — primary figure is the total Cut-medium track count; the secondary breakdown lists
per-ReleaseType Cut release counts, zero-count types already suppressed server-side. *@
<div class="hero-stat"> <div class="hero-stat">
<div class="hero-stat-num">47+</div> <div class="hero-stat-num">@_stats.CutTrackCount</div>
<div class="hero-stat-label">Live Sessions</div> <div class="hero-stat-label">Studio Cuts</div>
@if (_stats.CutReleaseTypeCounts.Count > 0)
{
<div class="hero-stat-breakdown">
@foreach (var row in _stats.CutReleaseTypeCounts)
{
<span class="hero-stat-breakdown-item">@row.Count @PluralizeReleaseType(row.ReleaseType, row.Count)</span>
}
</div>
}
</div> </div>
@* Mixes — primary figure is the Mix release count labelled "Sets"; the secondary figure is total
mix runtime as hh:mm. *@
<div class="hero-stat"> <div class="hero-stat">
<div class="hero-stat-num">2</div> <div class="hero-stat-num">@_stats.MixReleaseCount</div>
<div class="hero-stat-label">Members</div> <div class="hero-stat-label">Sets</div>
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
</div> </div>
@* Plays — live site-wide play total in the odometer (the "90s visitor counter" aesthetic is the
intended treatment). Secondary line is unique anonymous listeners (Phase 16 D7). Both read from
the same HomeStatsDto round-trip the other two cards use — no extra fetch. Reads zero until the
play-telemetry migration is applied. *@
<div class="hero-stat"> <div class="hero-stat">
<div class="hero-stat-num">&infin;</div> <div class="hero-stat-num hero-stat-odometer">@_stats.TotalPlays</div>
<div class="hero-stat-label">Drift Points</div> <div class="hero-stat-label">Plays</div>
<div class="hero-stat-sub">@_stats.UniqueListeners listeners</div>
</div> </div>
</div> </div>
@code {
[Inject] public required IStatsDataService StatsData { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
private const string PersistKey = "home-stats";
private HomeStatsDto _stats = new();
private bool _loaded;
private PersistingComponentStateSubscription _persistingSubscription;
protected override async Task OnInitializedAsync()
{
_persistingSubscription = PersistentState.RegisterOnPersisting(Persist);
// Bridge the prerendered fetch across the prerender -> WASM seam so the WASM boot does not
// re-fetch and flicker the figures (the TracksView persistent-state seam, applied to stats).
if (PersistentState.TryTakeFromJson<HomeStatsDto>(PersistKey, out var restored) && restored is not null)
{
_stats = restored;
_loaded = true;
return;
}
var result = await StatsData.GetHomeStats();
if (result is { Success: true, Value: { } stats })
{
_stats = stats;
_loaded = true;
}
}
// Only bridge a successful fetch. If prerender failed, persist nothing so the WASM pass re-fetches
// rather than restoring zeros — mirrors the guard on the medium-browse persist path.
private Task Persist()
{
if (_loaded)
PersistentState.PersistAsJson(PersistKey, _stats);
return Task.CompletedTask;
}
private static string PluralizeReleaseType(ReleaseType type, int count)
{
var label = type switch
{
ReleaseType.Single => "Single",
ReleaseType.EP => "EP",
ReleaseType.Album => "Album",
_ => type.ToString()
};
// EP pluralizes as "EPs"; Single/Album take a plain trailing s.
return count == 1 ? label : label + "s";
}
public void Dispose() => _persistingSubscription.Dispose();
}
@@ -27,6 +27,42 @@
margin-top: 0.4rem; margin-top: 0.4rem;
} }
/* Studio Cuts per-ReleaseType breakdown — mono caption rows below the label, reusing the label's
palette so the card reads as one block. */
.hero-stat-breakdown {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.5rem;
}
.hero-stat-breakdown-item {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
}
/* Mixes runtime sub-figure — sits under the label, slightly brighter than the label caption. */
.hero-stat-sub {
font-family: var(--deepdrft-font-mono);
font-size: 0.58rem;
letter-spacing: 0.12em;
color: rgba(250, 250, 248, 0.55);
margin-top: 0.5rem;
}
/* Plays placeholder — a light 90s visitor-counter / odometer embellishment over the existing
numeric treatment: monospace digits, boxed and tracked out like a mechanical counter. */
.hero-stat-odometer {
font-family: var(--deepdrft-font-mono);
letter-spacing: 0.18em;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(250, 250, 248, 0.12);
padding: 0.1rem 0.35rem;
display: inline-block;
}
@media (max-width: 599px) { @media (max-width: 599px) {
.hero-stat-row { .hero-stat-row {
flex-direction: column; flex-direction: column;
@@ -0,0 +1,127 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftModels.DTOs
@* Shared presentational queue list. Renders the ordered queue with the current track marked, and
(when Editable) drag-reorder handles + per-row remove controls. This is the single "view" both
the docked overlay (17.2) and the embedded panel (17.3) consume — one source, multiple views.
Purely presentational: owns no data fetch, no player wiring, and no IQueueService mutation of its
own. Order changes, removals, and row jumps are surfaced to the parent as EventCallbacks; the
parent calls the queue engine. It runs during prerender without JS interop (MudDropContainer's
drag work is client-only and inert when no drag occurs). *@
@if (Items is { Count: > 0 })
{
@if (Editable)
{
<MudDropContainer T="QueueRow" @ref="_dropContainer" Items="Rows" ItemsSelector="@((row, zone) => true)"
ItemDropped="OnItemDropped" Class="deepdrft-queue-list">
<ChildContent>
<MudDropZone T="QueueRow" Identifier="queue" Class="deepdrft-queue-zone" AllowReorder="true"/>
</ChildContent>
<ItemRenderer>
@RenderRow(context)
</ItemRenderer>
</MudDropContainer>
}
else
{
<div class="deepdrft-queue-list">
@foreach (var row in Rows)
{
@RenderRow(row)
}
</div>
}
}
@code {
/// <summary>The ordered tracks to render. Empty/null renders nothing.</summary>
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
/// <summary>
/// Index of the current track within <see cref="Items"/>, or -1 when none. The matching row is
/// rendered with a now-playing marker.
/// </summary>
[Parameter] public int CurrentIndex { get; set; } = -1;
/// <summary>
/// When true, rows show drag handles and a remove control and reorder is enabled. When false the
/// list is a read-only display (the embed's fixed-order shared queue).
/// </summary>
[Parameter] public bool Editable { get; set; }
/// <summary>
/// Raised when the user reorders a row: <c>(fromIndex, toIndex)</c>. The parent calls
/// <c>IQueueService.Move</c>. Only fires when <see cref="Editable"/>.
/// </summary>
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
/// <summary>
/// Raised when the user removes a row, carrying the row's index. The parent calls
/// <c>IQueueService.RemoveAt</c>. Only fires when <see cref="Editable"/>.
/// </summary>
[Parameter] public EventCallback<int> OnRemove { get; set; }
/// <summary>
/// Raised when the user clicks a row body to jump playback to it, carrying the row's index. The
/// parent decides whether/how to honour it (e.g. play from that index).
/// </summary>
[Parameter] public EventCallback<int> OnJump { get; set; }
private MudDropContainer<QueueRow>? _dropContainer;
// Index-tagged view rows. The index is the row's position in Items at render time and is the
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
private List<QueueRow> Rows =>
Items is null
? []
: Items.Select((track, index) => new QueueRow(index, track)).ToList();
private async Task OnItemDropped(MudItemDropInfo<QueueRow> dropInfo)
{
var from = dropInfo.Item!.Index;
var to = dropInfo.IndexInZone;
// MudDropContainer recomputes the list from the parent's next render; refresh its snapshot so
// the dragged row snaps back until the parent's Move re-flows the cascaded Items.
_dropContainer?.Refresh();
if (from == to) return;
await OnReorder.InvokeAsync((from, to));
}
private sealed record QueueRow(int Index, TrackDto Track);
private RenderFragment RenderRow(QueueRow row) => __builder =>
{
var isCurrent = row.Index == CurrentIndex;
<div class="@($"deepdrft-queue-row{(isCurrent ? " deepdrft-queue-row-current" : "")}")">
@if (Editable)
{
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" Size="Size.Small"
Class="deepdrft-queue-drag-handle"/>
}
<span class="deepdrft-queue-position">@(row.Index + 1)</span>
<div class="deepdrft-queue-body" @onclick="() => OnJump.InvokeAsync(row.Index)">
<span class="deepdrft-queue-title">@row.Track.TrackName</span>
@if (row.Track.Release is { Artist: var artist } && !string.IsNullOrWhiteSpace(artist))
{
<span class="deepdrft-queue-artist">@artist</span>
}
</div>
@if (isCurrent)
{
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
}
@* The current track cannot be removed (OQ3/OQ11): the queue empties only organically as the
current ends with nothing after it. Suppress the × on the current row only — reorder of the
current track is still allowed. *@
@if (Editable && !isCurrent)
{
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small"
Class="deepdrft-queue-remove" aria-label="Remove from queue"
OnClick="() => OnRemove.InvokeAsync(row.Index)"/>
}
</div>
};
}
@@ -0,0 +1,69 @@
@namespace DeepDrftPublic.Client.Controls
@using DeepDrftModels.DTOs
@* The docked player's queue panel: a screen-centered, mostly-square modal hosting the editable
QueueList (Phase 17 §3.2). The overlay shell, dismissal, and drag-safety are a direct lift of
WaveformVisualizerControlPopover (Phase 15 §4):
- MudOverlay (DarkBackground = mild tint, Modal = focus/scroll stay on the panel).
- Scrim OnClick closes; the panel stops click propagation so an inside click is not a dismissal.
- AutoClose left OFF; dismissal is the explicit scrim click only. A MudDropContainer drag that
ends outside the panel does not synthesise a click on the scrim, so a reorder drag never
dismisses (same drag-safety posture as the visualizer popover).
This host owns NO queue state and NO JS interop — it renders Items/CurrentIndex and forwards
QueueList's reorder/remove/jump callbacks plus a Clear action to the parent (AudioPlayerBar), which
holds the cascaded IQueueService. Purely presentational; prerender-safe. *@
<MudOverlay Visible="@Visible"
DarkBackground="true"
Modal="true"
OnClick="@OnClose"
Class="deepdrft-queue-overlay">
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
<div class="deepdrft-queue-modal-header">
<span class="deepdrft-queue-modal-title">Up Next</span>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Color="Color.Primary"
Disabled="@(!CanClear)"
OnClick="@OnClear"
Class="deepdrft-queue-clear">Clear</MudButton>
</div>
<div class="deepdrft-queue-modal-body">
<QueueList Items="Items"
CurrentIndex="CurrentIndex"
Editable="true"
OnReorder="OnReorder"
OnRemove="OnRemove"
OnJump="OnJump"/>
</div>
</div>
</MudOverlay>
@code {
/// <summary>Whether the overlay is shown. Owned by the parent (the Queue button toggles it).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The queue to render. Passed straight through to <see cref="QueueList"/>.</summary>
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
/// <summary>Index of the current track within <see cref="Items"/>, or -1 when none.</summary>
[Parameter] public int CurrentIndex { get; set; } = -1;
/// <summary>Raised when the scrim is clicked to dismiss the overlay.</summary>
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>Raised when Clear is pressed — empties the up-next, keeping the current track playing.</summary>
[Parameter] public EventCallback OnClear { get; set; }
/// <summary>Reorder callback forwarded from the hosted <see cref="QueueList"/>.</summary>
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
/// <summary>Remove callback forwarded from the hosted <see cref="QueueList"/>.</summary>
[Parameter] public EventCallback<int> OnRemove { get; set; }
/// <summary>Jump-to-track callback forwarded from the hosted <see cref="QueueList"/>.</summary>
[Parameter] public EventCallback<int> OnJump { get; set; }
// Clear is meaningful only when there is something beyond the current track to discard.
private bool CanClear => Items is { Count: > 1 };
}
@@ -42,35 +42,33 @@
} }
</MudStack> </MudStack>
@* Embed is a single-track affordance only; a release page is not a single-track embed (§3b.3). *@ @* Embed is offered in both modes: a track snippet (TrackEntryKey) or a whole-release
@if (!IsReleaseMode) snippet (ReleaseEntryKey) targeting FramePlayer's matching query param. *@
<MudDivider />
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
@if (_embed)
{ {
<MudDivider /> <MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudTextField Value="@EmbedSnippet"
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" /> T="string"
ReadOnly="true"
@if (_embed) Variant="Variant.Outlined"
{ Lines="3"
<MudStack Row AlignItems="AlignItems.Center" Spacing="1"> Margin="Margin.Dense"
<MudTextField Value="@EmbedSnippet" Class="deepdrft-share-embed-field" />
T="string" <MudStack AlignItems="AlignItems.Center" Spacing="0">
ReadOnly="true" <MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Variant="Variant.Outlined" Color="Color.Primary"
Lines="3" OnClick="@CopyEmbed"
Margin="Margin.Dense" aria-label="Copy embed snippet" />
Class="deepdrft-share-embed-field" /> @if (_embedCopied)
<MudStack AlignItems="AlignItems.Center" Spacing="0"> {
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" <MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
Color="Color.Primary" }
OnClick="@CopyEmbed"
aria-label="Copy embed snippet" />
@if (_embedCopied)
{
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
}
</MudStack>
</MudStack> </MudStack>
} </MudStack>
} }
</MudStack> </MudStack>
@@ -1,5 +1,7 @@
using DeepDrftModels.Enums; using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common; using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Helpers;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -7,19 +9,19 @@ namespace DeepDrftPublic.Client.Controls;
/// <summary> /// <summary>
/// Share affordance with two modes from one source of clipboard/popover-chrome logic /// Share affordance with two modes from one source of clipboard/popover-chrome logic
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an /// (Phase 11 §3b). Both modes offer a canonical-link copy plus an optional iframe embed snippet.
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only — /// Track mode (<see cref="EntryKey"/> set) embeds a single track (FramePlayer?TrackEntryKey=...);
/// it copies the absolute form of the release's canonical detail URL and hides the embed /// release mode (<see cref="ReleaseEntryKey"/> set) copies the release's canonical detail URL and
/// affordance, since a release page is not a single-track embed. Clipboard writes go through /// embeds the whole release (FramePlayer?ReleaseEntryKey=...), which queues and advances through its
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a /// tracks on first play. Clipboard writes go through navigator.clipboard; each copy shows a transient
/// short delay. /// "Copied!" confirmation that resets after a short delay.
/// </summary> /// </summary>
public partial class SharePopover : ComponentBase, IDisposable public partial class SharePopover : ComponentBase, IDisposable
{ {
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary> /// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
[Parameter] public string? EntryKey { get; set; } [Parameter] public string? EntryKey { get; set; }
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary> /// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and embeds the whole release.</summary>
[Parameter] public string? ReleaseEntryKey { get; set; } [Parameter] public string? ReleaseEntryKey { get; set; }
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary> /// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
@@ -27,6 +29,8 @@ public partial class SharePopover : ComponentBase, IDisposable
[Inject] public required NavigationManager Navigation { get; set; } [Inject] public required NavigationManager Navigation { get; set; }
[Inject] public required IJSRuntime JS { get; set; } [Inject] public required IJSRuntime JS { get; set; }
[Inject] public required ShareTracker ShareTracker { get; set; }
[Inject] public required IAnonIdProvider AnonId { get; set; }
private bool IsReleaseMode => ReleaseEntryKey is not null; private bool IsReleaseMode => ReleaseEntryKey is not null;
@@ -56,10 +60,21 @@ public partial class SharePopover : ComponentBase, IDisposable
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}"; private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
private string EmbedSnippet => // FramePlayer's query param selects the embed mode: ReleaseEntryKey queues the whole release,
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>"""; // TrackEntryKey stages a single track. The iframe chrome is identical in both modes.
private string EmbedSnippet => IsReleaseMode
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
private void Toggle() => _open = !_open; private async Task Toggle()
{
_open = !_open;
// Warm the anon-id cache when the popover opens (wave 16.3) so a copy-share fired moments later
// reads a populated AnonId.Current. Idempotent and best-effort — if it fails the share simply
// carries no anonId. Opening is interactive, so the localStorage interop is available here.
if (_open)
await AnonId.EnsureLoadedAsync();
}
private void Close() => _open = false; private void Close() => _open = false;
@@ -67,6 +82,14 @@ public partial class SharePopover : ComponentBase, IDisposable
{ {
if (await CopyToClipboard(LinkUrl)) if (await CopyToClipboard(LinkUrl))
{ {
// Record a share only after the clipboard write succeeds (§1b). Release mode targets the
// release EntryKey; track mode targets the track EntryKey. The tracker debounces repeat
// copies of the same (target, channel) into one event.
if (IsReleaseMode)
ShareTracker.RecordShare(ShareTargetType.Release, ReleaseEntryKey!, ShareChannel.Link);
else if (!string.IsNullOrWhiteSpace(EntryKey))
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Link);
_linkCopied = true; _linkCopied = true;
await ResetAfterDelay(() => _linkCopied = false); await ResetAfterDelay(() => _linkCopied = false);
} }
@@ -76,6 +99,11 @@ public partial class SharePopover : ComponentBase, IDisposable
{ {
if (await CopyToClipboard(EmbedSnippet)) if (await CopyToClipboard(EmbedSnippet))
{ {
// Embed is a single-track affordance only (release mode hides it), so this always targets a
// track with channel = embed.
if (!string.IsNullOrWhiteSpace(EntryKey))
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Embed);
_embedCopied = true; _embedCopied = true;
await ResetAfterDelay(() => _embedCopied = false); await ResetAfterDelay(() => _embedCopied = false);
} }
@@ -0,0 +1,21 @@
namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
/// </summary>
public static class EmbedSnippetBuilder
{
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
public static string ForTrack(string baseUri, string trackEntryKey)
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
public static string ForRelease(string baseUri, string releaseEntryKey)
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
private static string Frame(string src)
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
}
@@ -0,0 +1,19 @@
namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Formats a runtime expressed in seconds as a compact <c>hh:mm</c> string for the home hero stat row.
/// Hours are not zero-padded and may exceed two digits (mixes are few, so a large total simply renders
/// "123:45"); minutes are always two digits. Negative or non-finite inputs clamp to "0:00".
/// </summary>
public static class RuntimeFormat
{
public static string ToHoursMinutes(double totalSeconds)
{
if (double.IsNaN(totalSeconds) || double.IsInfinity(totalSeconds) || totalSeconds <= 0)
return "0:00";
var total = TimeSpan.FromSeconds(totalSeconds);
var hours = (int)total.TotalHours;
return $"{hours}:{total.Minutes:D2}";
}
}
@@ -1,8 +1,11 @@
<footer class="deepdrft-footer"> <footer class="deepdrft-footer">
<div class="deepdrft-footer-logo d-none d-sm-inline">Deep <span>DRFT</span></div> <div class="deepdrft-footer-main">
<ul class="deepdrft-footer-links"> <div class="deepdrft-footer-logo d-none d-sm-inline">Deep <span>DRFT</span></div>
<li><a href="#">About</a></li> <ul class="deepdrft-footer-links">
<li><a href="#">Contact</a></li> <li><a href="/about">About</a></li>
</ul> <li><a href="#">Contact</a></li>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div> </ul>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
</div>
<p class="deepdrft-footer-privacy">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag&#8217;s gone.</p>
</footer> </footer>
@@ -6,6 +6,12 @@
background: var(--deepdrft-white); background: var(--deepdrft-white);
border-top: 1px solid var(--deepdrft-border); border-top: 1px solid var(--deepdrft-border);
padding: 3rem; padding: 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.deepdrft-footer-main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -51,6 +57,16 @@
color: var(--deepdrft-muted); color: var(--deepdrft-muted);
} }
.deepdrft-footer-privacy {
font-family: var(--deepdrft-font-mono);
font-size: 0.55rem;
letter-spacing: 0.08em;
color: var(--deepdrft-muted);
opacity: 0.7;
margin: 0;
line-height: 1.6;
}
@media (max-width: 440px) { @media (max-width: 440px) {
.deepdrft-footer { .deepdrft-footer {
padding: 1.5rem; padding: 1.5rem;
@@ -5,7 +5,21 @@
@* Desktop Menu *@ @* Desktop Menu *@
<div class="d-none d-sm-flex"> <div class="d-none d-sm-flex">
<nav class="@NavClass"> <nav class="@NavClass">
<a class="dd-nav-brand" href="/">Deep DRFT</a> <MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "/>
<span class="mx-2">Deep DRFT</span>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24 "/>
</a>
</MudStack>
<ul class="dd-nav-links"> <ul class="dd-nav-links">
@foreach (var navPage in Pages.MenuPages) @foreach (var navPage in Pages.MenuPages)
@@ -27,7 +41,7 @@
</ul> </ul>
<div class="dd-nav-actions"> <div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" /> <StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;"/>
</div> </div>
</nav> </nav>
</div> </div>
@@ -35,6 +35,8 @@
/* Brand */ /* Brand */
.dd-nav-brand { .dd-nav-brand {
display: flex;
align-items: center;
font-family: var(--deepdrft-font-mono); font-family: var(--deepdrft-font-mono);
font-size: 0.75rem; font-size: 0.75rem;
letter-spacing: 0.22em; letter-spacing: 0.22em;
+36 -31
View File
@@ -106,7 +106,7 @@
<div class="bio-portrait-placeholder" aria-hidden="true"></div> <div class="bio-portrait-placeholder" aria-hidden="true"></div>
} }
</div> </div>
<div class="bio-caption">@member.Name &middot; @member.Role</div> <div class="bio-caption">@member.Role</div>
<div class="bio-meta"> <div class="bio-meta">
<div class="bio-name">@member.Name</div> <div class="bio-name">@member.Name</div>
@if (!string.IsNullOrWhiteSpace(member.Bio)) @if (!string.IsNullOrWhiteSpace(member.Bio))
@@ -192,7 +192,7 @@
ImageHeight="auto" ImageHeight="auto"
ImageWidth="100%" ImageWidth="100%"
ParallaxSpeed="0.35" /> ParallaxSpeed="0.35" />
<figcaption class="figure-caption">the live rig</figcaption> <figcaption class="figure-caption">the studio rig</figcaption>
</figure> </figure>
</div> </div>
</div> </div>
@@ -223,35 +223,40 @@
@* Medium triptych — one-line frame of each medium; definitions, not a re-pitch. @* Medium triptych — one-line frame of each medium; definitions, not a re-pitch.
A stacked editorial list rather than Home's card grid. *@ A stacked editorial list rather than Home's card grid. *@
<ul class="medium-list"> <MudGrid Spacing="16">
<li class="medium-row"> <MudItem xs="12" md="6">
<a href="/cuts"> <ul class="medium-list">
<span class="medium-row-name">Cuts</span> <li class="medium-row">
<span class="medium-row-desc">Studio work, composed and finished.</span> <a href="/cuts">
</a> <span class="medium-row-name">Cuts</span>
</li> <span class="medium-row-desc">Studio work, composed and finished.</span>
<li class="medium-row"> </a>
<a href="/sessions"> </li>
<span class="medium-row-name">Sessions</span> <li class="medium-row">
<span class="medium-row-desc">Live, caught once, never the same twice.</span> <a href="/sessions">
</a> <span class="medium-row-name">Sessions</span>
</li> <span class="medium-row-desc">Live, caught once, never the same twice.</span>
<li class="medium-row"> </a>
<a href="/mixes"> </li>
<span class="medium-row-name">Mixes</span> <li class="medium-row">
<span class="medium-row-desc">Uninterrupted sets, start to finish.</span> <a href="/mixes">
</a> <span class="medium-row-name">Mixes</span>
</li> <span class="medium-row-desc">Uninterrupted sets, start to finish.</span>
</ul> </a>
</li>
@* The live turn — "on the street, in the swamp": the identity beyond releases. </ul>
</MudItem>
@* The live turn — "on the street, in the swamp": the identity beyond releases.
A left-breaking pull-quote at large serif scale. *@ A left-breaking pull-quote at large serif scale. *@
<blockquote class="pull-quote"> <MudItem xs="12" md="6">
<span class="pull-eyebrow">Beyond the Releases</span> <blockquote class="pull-quote">
<p> <span class="pull-eyebrow">Beyond the Releases</span>
But that's just the releases. We're also out there &mdash; on the street, in the swamp, with a PA, a generator, and a bunch of good vibes. <p>
</p> But that's just the releases. We're also out there &mdash; on the street, in the swamp, with a PA, a generator, and a bunch of good vibes.
</blockquote> </p>
</blockquote>
</MudItem>
</MudGrid>
</div> </div>
</div> </div>
@@ -362,7 +367,7 @@
new( new(
Name: "Daniel Harvey", Name: "Daniel Harvey",
Role: "Production · Sound Design · Live", Role: "Production · Sound Design · Live",
Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead at from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by modern underground Detroit techno. Art & engineering cannot be separated: custom plugins, hardware recording & performance rigs, the tools behind the tracks are just as important as the finished sound. To him the science and the math matter as much as the beauty — tension and release, built deliberately.", Bio: "Daniel started on drums at ten and embarked in electronic music at seventeen — synthesizers first. A metalhead at from a young age, he spent ten years as an engineer living near Detroit filling the nights with synthesized tones and rhythms, shaped most of all by the thriving local underground techno scene.\n\nNow back home in the lowcountry, Daniel carries the varied sounds of his past into a new future, inspired by the wandering cypress swamps and soulful sunsets over the Ashley River.\n\nArt & engineering cannot be separated: custom plugins, hardware recording & performance rigs; the tools behind the tracks are just as important as the finished sound. To him the science and the math matter as much as the beauty — tension and release, built deliberately.",
PortraitImage1: "img/dd-daniel-bw.jpeg", PortraitImage1: "img/dd-daniel-bw.jpeg",
PortraitImage2: "img/dd-daniel.jpeg"), PortraitImage2: "img/dd-daniel.jpeg"),
]; ];
+1 -2
View File
@@ -271,7 +271,7 @@
} }
.bio-portrait { .bio-portrait {
width: 100%; width: 80%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
overflow: hidden; overflow: hidden;
border: 1px solid var(--deepdrft-border); border: 1px solid var(--deepdrft-border);
@@ -484,7 +484,6 @@
/* The sharp pull-quote — breaks LEFT into the rail margin at large serif scale. */ /* The sharp pull-quote — breaks LEFT into the rail margin at large serif scale. */
.pull-quote { .pull-quote {
margin: 0; margin: 0;
margin-left: -7rem;
max-width: 70ch; max-width: 70ch;
} }
@@ -94,6 +94,10 @@ else
Play Play
</MudButton> </MudButton>
@* Append the whole album (TrackNumber order) to the queue — same ordered list
header Play uses. Append-only: does not start playback (AC7/AC8). *@
<AddToQueueButton ReleaseTracks="@ViewModel.Tracks" />
@* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@ @* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" /> <SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
</div> </div>
@@ -138,6 +142,9 @@ else
OnToggle="@(() => PlayTrack(track, index))" /> OnToggle="@(() => PlayTrack(track, index))" />
</div> </div>
<span class="cut-detail-track-name text-truncate">@track.TrackName</span> <span class="cut-detail-track-name text-truncate">@track.TrackName</span>
@* Append this single track to the queue (append-only, does not play). *@
<AddToQueueButton Track="@track" />
<SharePopover EntryKey="@track.EntryKey" />
</div> </div>
} }
</div> </div>
+47 -10
View File
@@ -2,6 +2,7 @@
@using DeepDrftPublic.Client.Controls.AudioPlayerBar @using DeepDrftPublic.Client.Controls.AudioPlayerBar
@using DeepDrftPublic.Client.Layout @using DeepDrftPublic.Client.Layout
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@using DeepDrftPublic.Client.ViewModels
@page "/FramePlayer" @page "/FramePlayer"
@layout EmbedLayout @layout EmbedLayout
@@ -10,28 +11,64 @@
@code { @code {
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; } [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[CascadingParameter] public IQueueService? Queue { get; set; }
// Two mutually-exclusive embed targets. ReleaseEntryKey wins if both are somehow supplied — a
// release embed is the richer surface, and the single-track path would otherwise mask it.
[SupplyParameterFromQuery] public string? ReleaseEntryKey { get; set; }
[SupplyParameterFromQuery] public string? TrackEntryKey { get; set; } [SupplyParameterFromQuery] public string? TrackEntryKey { get; set; }
[Inject] public required ITrackDataService TrackDataService { get; set; } [Inject] public required ITrackDataService TrackDataService { get; set; }
[Inject] public required FramePlayerViewModel ViewModel { get; set; }
private string? _stagedKey; private string? _stagedKey;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
if (PlayerService is null || string.IsNullOrWhiteSpace(TrackEntryKey)) return; if (PlayerService is null) return;
// OnParametersSetAsync can fire repeatedly (and once per render pass); only act when the if (!string.IsNullOrWhiteSpace(ReleaseEntryKey))
// key actually changes so we don't re-fetch on every parameter set. {
if (TrackEntryKey == _stagedKey) return; await StageRelease(ReleaseEntryKey);
_stagedKey = TrackEntryKey; }
else if (!string.IsNullOrWhiteSpace(TrackEntryKey))
{
await StageSingleTrack(TrackEntryKey);
}
}
var result = await TrackDataService.GetTrack(TrackEntryKey); // Release embed: resolve the release's ordered tracks, stage the first so the bar shows the release
// ready, and arm the queue with the whole list. No JS interop here (StageTrack and Arm are both
// interop-free), so this runs identically during prerender and after WASM boot. The first play
// click (handled in AudioPlayerBar) routes through Queue.PlayRelease, queueing the full release.
private async Task StageRelease(string releaseEntryKey)
{
// OnParametersSetAsync can fire repeatedly; only act when the key actually changes.
if (releaseEntryKey == _stagedKey) return;
_stagedKey = releaseEntryKey;
await ViewModel.Load(releaseEntryKey);
if (ViewModel.Tracks.Count == 0) return; // No tracks: leave the bar idle.
await PlayerService!.StageTrack(ViewModel.Tracks[0]);
Queue?.Arm(ViewModel.Tracks);
}
// Single-track embed: unchanged behaviour — stage exactly the requested track. The first play
// click streams it directly (the queue stays empty/disarmed).
private async Task StageSingleTrack(string trackEntryKey)
{
if (trackEntryKey == _stagedKey) return;
_stagedKey = trackEntryKey;
var result = await TrackDataService.GetTrack(trackEntryKey);
if (result.Success && result.Value is not null) if (result.Success && result.Value is not null)
{ {
// Stage only — no audio context, no streaming. The browser blocks audio until a user // Stage only — no audio context, no streaming. The browser blocks audio until a user
// gesture, so the embed shows the track ready and the first play click (handled in // gesture, so the embed shows the track ready and the first play click calls
// AudioPlayerBar) calls SelectTrackStreaming. This also keeps this pass free of JS // SelectTrackStreaming. This keeps this pass free of JS interop, so it works whether it
// interop, so it works whether it runs during prerender or after WASM is interactive. // runs during prerender or after WASM is interactive.
await PlayerService.StageTrack(result.Value); await PlayerService!.StageTrack(result.Value);
} }
// On failure, leave the bar idle; a stream-level error surfaces via PlayerService.ErrorMessage. // On failure, leave the bar idle; a stream-level error surfaces via PlayerService.ErrorMessage.
} }
@@ -81,6 +81,8 @@ else
@if (ViewModel.Track is not null) @if (ViewModel.Track is not null)
{ {
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" /> <PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
@* Append-only: queues the mix's single track without starting playback. *@
<AddToQueueButton Track="@ViewModel.Track" Size="Size.Large" />
} }
</PlayContent> </PlayContent>
</ReleaseHeroOverlay> </ReleaseHeroOverlay>
@@ -79,6 +79,8 @@ else
@if (ViewModel.Track is not null) @if (ViewModel.Track is not null)
{ {
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" /> <PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
@* Append-only: queues the session's single track without starting playback. *@
<AddToQueueButton Track="@ViewModel.Track" Size="Size.Large" />
} }
</PlayContent> </PlayContent>
</ReleaseHeroOverlay> </ReleaseHeroOverlay>
@@ -0,0 +1,41 @@
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IAnonIdProvider"/> over the <c>window.DeepDrftAnonId</c> TS interop. Reads the
/// first-party <c>localStorage</c> GUID once and caches it for the session, so the synchronous emit paths
/// read it with no JS hop. Scoped (per-session) like the other telemetry collaborators; the underlying
/// token itself outlives the session in <c>localStorage</c> — the cache just avoids repeated interop.
/// </summary>
public sealed class AnonIdProvider : IAnonIdProvider
{
private readonly IJSRuntime _js;
private bool _loaded;
public AnonIdProvider(IJSRuntime js)
{
_js = js;
}
public string? Current { get; private set; }
public async ValueTask EnsureLoadedAsync()
{
if (_loaded) return;
try
{
// The module returns null when localStorage is unavailable; we store that null and still
// mark loaded so we don't retry every emit. A genuine interop failure (module not yet
// imported, prerender) is caught below and leaves _loaded false so a later warm can succeed.
Current = await _js.InvokeAsync<string?>("DeepDrftAnonId.get");
_loaded = true;
}
catch
{
// Interop unavailable (prerender / module not loaded). Leave Current null and unloaded so a
// subsequent warm retries — telemetry simply omits the id until then.
}
}
}
@@ -260,6 +260,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
private async Task OnProgressCallback(double currentTime) private async Task OnProgressCallback(double currentTime)
{ {
CurrentTime = currentTime; CurrentTime = currentTime;
// Telemetry hook (Phase 16 §2.1): a subclass advances the play-session high-water mark here, on
// the same throttled tick the UI already consumes. Base implementation is a no-op.
OnProgressTick(currentTime);
await NotifyStateChanged(); await NotifyStateChanged();
} }
@@ -270,6 +273,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
IsLoaded = false; IsLoaded = false;
CurrentTime = 0; CurrentTime = 0;
Duration = null; Duration = null;
// Telemetry hook: organic end closes the play session (the bucket reflects how far they got)
// BEFORE the state notification and TrackEnded fan-out, so the session that just ended is the
// one recorded — not whatever a queue auto-advance opens next. Base implementation is a no-op.
OnPlaybackEnded();
await NotifyStateChanged(); await NotifyStateChanged();
// Fire AFTER the state notification so any queue orchestrator that advances on this // Fire AFTER the state notification so any queue orchestrator that advances on this
@@ -279,6 +286,18 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
TrackEnded?.Invoke(); TrackEnded?.Invoke();
} }
/// <summary>
/// Telemetry seam (Phase 16): called on each progress tick with the current playback position. The
/// streaming subclass overrides this to advance the play-session high-water mark. No-op in the base.
/// </summary>
protected virtual void OnProgressTick(double currentTime) { }
/// <summary>
/// Telemetry seam (Phase 16): called on organic end-of-stream, before <see cref="TrackEnded"/> fires.
/// The streaming subclass overrides this to close the play session. No-op in the base.
/// </summary>
protected virtual void OnPlaybackEnded() { }
protected async Task EnsureInitializedAsync() protected async Task EnsureInitializedAsync()
{ {
@@ -0,0 +1,60 @@
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
/// touches <see cref="IJSRuntime"/> string identifiers directly. All calls are best-effort: a JS
/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
/// never throw into the UI or the playback path.
/// </summary>
public sealed class BeaconInterop
{
private readonly IJSRuntime _js;
public BeaconInterop(IJSRuntime js)
{
_js = js;
}
/// <summary>Queue a fire-and-forget POST of a JSON body to the given absolute URL.</summary>
public async Task SendAsync(string url, string json)
{
try
{
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
}
catch
{
// Module not loaded / not interactive yet — drop the event silently.
}
}
/// <summary>Register a .NET unload callback (fires on pagehide / visibility→hidden) under a key.</summary>
public async Task RegisterUnloadAsync<T>(string key, DotNetObjectReference<T> dotNetRef, string methodName)
where T : class
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
}
catch
{
// Best-effort — without the unload handler, mid-play tab-close simply isn't recorded.
}
}
/// <summary>Detach a previously-registered unload callback.</summary>
public async Task UnregisterUnloadAsync(string key)
{
try
{
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
}
catch
{
// Disposal best-effort.
}
}
}
@@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
/// awaiting and its failure is irrelevant. The current <c>anonId</c> (wave 16.3) is read synchronously
/// from the warmed <see cref="IAnonIdProvider"/> cache and omitted when null (storage unavailable / not
/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally.
/// </summary>
public sealed class BeaconPlayEventSink : IPlayEventSink
{
// Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than
// sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism;
// it does not change the integer enum encoding the 16.1 contract already relies on.
private static readonly JsonSerializerOptions BeaconJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _playUrl;
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_anonId = anonId;
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
// trailing slash; the route does not lead with one.
_playUrl = $"{navigation.BaseUri}api/event/play";
}
public void EmitPlay(string trackEntryKey, PlayBucket bucket)
{
var json = JsonSerializer.Serialize(new PlayEventDto
{
TrackEntryKey = trackEntryKey,
Bucket = bucket,
AnonId = _anonId.Current,
}, BeaconJson);
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
// act on the result either way.
_ = _beacon.SendAsync(_playUrl, json);
}
}
@@ -0,0 +1,32 @@
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Supplies the client-minted anonymous listener id (Phase 16 §3, wave 16.3, D5 Option A) to the
/// fire-and-forget telemetry sinks. The id is a random first-party <c>localStorage</c> GUID — opaque, no
/// PII, no fingerprinting, clearable. Split into an async warm (<see cref="EnsureLoadedAsync"/>) and a
/// synchronous read (<see cref="Current"/>) so the existing sync emit paths (the beacon sink, the share
/// tracker) need no async signature change: a caller warms the cache when it goes interactive, and the
/// emit then reads the cached value with no JS round-trip on the close/unload path.
///
/// <para>
/// Degrades to null when <c>localStorage</c> is unavailable (private mode / blocked / partitioned
/// third-party iframe) — the sink then omits the id and sends an anonId-less event. Over-counting is the
/// accepted direction of error (§3); a missing id never throws.
/// </para>
/// </summary>
public interface IAnonIdProvider
{
/// <summary>
/// The cached anon id, or null if not yet warmed or if storage is unavailable. Synchronous and safe
/// to read from the player close path and the page-unload handler, neither of which can await.
/// </summary>
string? Current { get; }
/// <summary>
/// Warm the cache from <c>localStorage</c> via JS interop (minting on first visit). Idempotent — only
/// the first successful read populates the cache; later calls are no-ops. Best-effort: a JS failure
/// (interop unavailable during prerender, storage blocked) leaves <see cref="Current"/> null and never
/// throws.
/// </summary>
ValueTask EnsureLoadedAsync();
}
@@ -0,0 +1,16 @@
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// The emit seam for the <see cref="PlayTracker"/> (Phase 16 §2.1). The tracker owns the session
/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
/// it hands a finished classification to a sink. The production sink fires a <c>sendBeacon</c> POST to
/// <c>api/event/play</c>; tests substitute a fake sink to assert floor and bucket behaviour with no
/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
/// </summary>
public interface IPlayEventSink
{
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
void EmitPlay(string trackEntryKey, PlayBucket bucket);
}
@@ -38,6 +38,15 @@ public interface IQueueService
/// <summary>The current track, or null when the queue is empty.</summary> /// <summary>The current track, or null when the queue is empty.</summary>
TrackDto? Current { get; } TrackDto? Current { get; }
/// <summary>
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
/// </summary>
bool IsArmed { get; }
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary> /// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
bool HasNext { get; } bool HasNext { get; }
@@ -59,12 +68,64 @@ public interface IQueueService
/// </summary> /// </summary>
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0); Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary> /// <summary>
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
/// keeps the loaded release queued so it advances through its tracks. No-op when
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
/// </summary>
void Arm(IEnumerable<TrackDto> tracks);
/// <summary>
/// Begins playback of an armed queue (see <see cref="Arm"/>): streams the current track and clears
/// <see cref="IsArmed"/>, leaving the loaded list and position intact so auto-advance carries on
/// through the release. This is the first-gesture entry point the embed bar calls. No-op (and stays
/// disarmed) when the queue is not armed or is empty — so it never double-streams or disturbs a
/// queue already playing.
/// </summary>
Task Start();
/// <summary>
/// Appends a track to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// </summary>
void Enqueue(TrackDto track); void Enqueue(TrackDto track);
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary> /// <summary>
/// Appends tracks to the end of the queue without changing what is currently playing.
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
/// </summary>
void EnqueueRange(IEnumerable<TrackDto> tracks); void EnqueueRange(IEnumerable<TrackDto> tracks);
/// <summary>
/// Reorders the queue, moving the track at <paramref name="fromIndex"/> to
/// <paramref name="toIndex"/>, and re-emits <see cref="QueueChanged"/>. Adjusts
/// <see cref="CurrentIndex"/> so the <em>same track</em> stays current across the move — it does
/// not restart, re-stream, or interrupt the currently-playing track. Interop-free; safe during
/// prerender. No-op (no throw, no <see cref="QueueChanged"/>) when either index is out of range
/// or the indices are equal.
/// </summary>
void Move(int fromIndex, int toIndex);
/// <summary>
/// Removes the track at <paramref name="index"/> and re-emits <see cref="QueueChanged"/>. Does
/// not touch playback (the player stays a single-track device): removing the current track does
/// not stop it — the playing track runs to its natural end while <see cref="CurrentIndex"/>
/// resolves to the new occupant of that slot (the next track) so the next auto-advance/skip is
/// coherent. Removing a track before the current decrements <see cref="CurrentIndex"/> (the same
/// track stays current); removing after the current leaves it unchanged. Removing the last
/// remaining track empties the queue (<see cref="CurrentIndex"/> == -1, dormant). Interop-free;
/// safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when
/// <paramref name="index"/> is out of range.
/// </summary>
void RemoveAt(int index);
/// <summary> /// <summary>
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false. /// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
/// </summary> /// </summary>
@@ -77,4 +138,15 @@ public interface IQueueService
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary> /// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
void Clear(); void Clear();
/// <summary>
/// Empties the up-next while keeping the currently-playing track: removes every item except
/// <see cref="Current"/>, leaving it as the sole remaining item at <see cref="CurrentIndex"/> == 0,
/// and re-emits <see cref="QueueChanged"/>. Unlike <see cref="Clear"/> (which empties everything and
/// goes dormant), this preserves what is playing — the player is never stopped and the current track
/// stays queued, so playback continues uninterrupted while the rest of the queue is discarded.
/// Interop-free; safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when the queue
/// is empty/dormant or already holds only the current track.
/// </summary>
void ClearUpcoming();
} }
@@ -0,0 +1,15 @@
using DeepDrftModels.DTOs;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Home-stats read abstraction. Both SSR and WASM renders are served by <c>StatsClientDataService</c>
/// in this assembly, which delegates to <see cref="Clients.StatsClient"/> over HTTP. Components inject
/// this single seam so they do not branch on render mode — mirrors <see cref="IReleaseDataService"/>.
/// </summary>
public interface IStatsDataService
{
/// <summary>Aggregate figures behind the public home hero stat row, in one round-trip.</summary>
Task<ApiResult<HomeStatsDto>> GetHomeStats();
}
@@ -0,0 +1,129 @@
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on
/// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch
/// / stop / page-unload — and emits at most one play event per session, classified into a completion
/// bucket, but only once the engagement floor is crossed (§1d / D2).
///
/// <para>
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> and
/// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable
/// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires
/// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a
/// seek-beyond-buffer re-fetch is the same play, not a new one (§1d).
/// </para>
///
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
/// </summary>
public sealed class PlayTracker
{
// Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR
// 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything
// longer floors on the 3-second wall. Single tunable constant pair; one place to retune.
private const double FloorSeconds = 3.0;
private const double FloorFraction = 0.05;
// Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%].
private const double SampledThreshold = 0.30;
private const double CompleteThreshold = 0.80;
private readonly IPlayEventSink _sink;
private string? _trackEntryKey;
private double? _duration;
private double _highWater;
private bool _closed;
public PlayTracker(IPlayEventSink sink)
{
_sink = sink;
}
/// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
public bool HasOpenSession => _trackEntryKey is not null && !_closed;
/// <summary>
/// Open a session for the track whose playback just started. Supersedes any still-open session by
/// closing it first — a track-switch that did not route through <see cref="Close"/> still records the
/// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
/// </summary>
public void OnPlaybackStarted(string trackEntryKey)
{
if (HasOpenSession)
Close();
_trackEntryKey = trackEntryKey;
_duration = null;
_highWater = 0;
_closed = false;
}
/// <summary>
/// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded
/// value is taken, matching the player which sets <c>Duration</c> exactly once.
/// </summary>
public void SetDuration(double durationSeconds)
{
if (!HasOpenSession) return;
if (_duration is null && durationSeconds > 0)
_duration = durationSeconds;
}
/// <summary>
/// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it,
/// so a seek-to-end-then-back still classifies by the furthest point reached (§1d).
/// </summary>
public void OnProgress(double currentTime)
{
if (!HasOpenSession) return;
if (currentTime > _highWater)
_highWater = currentTime;
}
/// <summary>
/// Close the open session and emit a play event if the engagement floor was crossed; below the floor
/// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
/// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
/// first call emits.
/// </summary>
public void Close()
{
if (!HasOpenSession)
{
// Mark closed even if never opened so a stray late callback cannot reopen-then-emit.
_closed = true;
return;
}
var key = _trackEntryKey!;
_closed = true;
// Without a known duration there is no fraction to classify and no floor to test — drop the
// session. In practice the WAV header sets duration well before any meaningful listen, so this
// only drops listens that ended before the header parsed (i.e. effectively no listen).
if (_duration is not { } duration || duration <= 0)
return;
var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0);
if (!CrossesFloor(_highWater, duration))
return;
_sink.EmitPlay(key, Classify(fraction));
}
// The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
private static bool CrossesFloor(double highWater, double duration)
{
var floor = Math.Min(FloorSeconds, FloorFraction * duration);
return highWater >= floor;
}
private static PlayBucket Classify(double fraction)
=> fraction < SampledThreshold ? PlayBucket.Partial
: fraction <= CompleteThreshold ? PlayBucket.Sampled
: PlayBucket.Complete;
}
+113 -3
View File
@@ -26,6 +26,8 @@ public sealed class QueueService : IQueueService, IDisposable
public int CurrentIndex { get; private set; } = -1; public int CurrentIndex { get; private set; } = -1;
public bool IsArmed { get; private set; }
public TrackDto? Current => public TrackDto? Current =>
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null; CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
@@ -54,7 +56,7 @@ public sealed class QueueService : IQueueService, IDisposable
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0) public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
{ {
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList(); var list = tracks.ToList();
if (list.Count == 0) return; if (list.Count == 0) return;
var start = Math.Clamp(startIndex, 0, list.Count - 1); var start = Math.Clamp(startIndex, 0, list.Count - 1);
@@ -62,14 +64,42 @@ public sealed class QueueService : IQueueService, IDisposable
_items.Clear(); _items.Clear();
_items.AddRange(list); _items.AddRange(list);
CurrentIndex = start; CurrentIndex = start;
// Playback is now starting for real, so the queue is no longer merely armed.
IsArmed = false;
QueueChanged?.Invoke(); QueueChanged?.Invoke();
await PlayCurrent(); await PlayCurrent();
} }
public void Arm(IEnumerable<TrackDto> tracks)
{
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
if (list.Count == 0) return;
_items.Clear();
_items.AddRange(list);
CurrentIndex = 0;
IsArmed = true;
// No PlayCurrent: arming is interop-free state only. The first play gesture drives Start().
QueueChanged?.Invoke();
}
public async Task Start()
{
if (!IsArmed) return;
IsArmed = false;
QueueChanged?.Invoke();
await PlayCurrent();
}
public void Enqueue(TrackDto track) public void Enqueue(TrackDto track)
{ {
_items.Add(track); _items.Add(track);
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
// called here, so this stays interop-free and prerender-safe.
if (CurrentIndex == -1)
CurrentIndex = 0;
QueueChanged?.Invoke(); QueueChanged?.Invoke();
} }
@@ -77,14 +107,78 @@ public sealed class QueueService : IQueueService, IDisposable
{ {
var before = _items.Count; var before = _items.Count;
_items.AddRange(tracks); _items.AddRange(tracks);
if (_items.Count != before) if (_items.Count == before) return;
QueueChanged?.Invoke(); // OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
// without playing. The first newly-appended track becomes current.
if (CurrentIndex == -1)
CurrentIndex = 0;
QueueChanged?.Invoke();
}
public void Move(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
if (fromIndex < 0 || fromIndex >= _items.Count) return;
if (toIndex < 0 || toIndex >= _items.Count) return;
var moved = _items[fromIndex];
_items.RemoveAt(fromIndex);
_items.Insert(toIndex, moved);
// Keep the same track current across the reorder. No playback is touched (C2): we only
// recompute which index the current track now sits at.
if (CurrentIndex == fromIndex)
{
CurrentIndex = toIndex;
}
else if (fromIndex < CurrentIndex && toIndex >= CurrentIndex)
{
// The current track shifted one slot toward the front to fill the vacated lower slot.
CurrentIndex--;
}
else if (fromIndex > CurrentIndex && toIndex <= CurrentIndex)
{
// An item inserted at/above the current slot pushed the current track one slot back.
CurrentIndex++;
}
QueueChanged?.Invoke();
}
public void RemoveAt(int index)
{
if (index < 0 || index >= _items.Count) return;
_items.RemoveAt(index);
if (_items.Count == 0)
{
// Last remaining track removed → empty + dormant. Does not stop the player (C2).
CurrentIndex = -1;
}
else if (index < CurrentIndex)
{
// A track before the current was removed: the same track stays current at a lower index.
CurrentIndex--;
}
else if (index == CurrentIndex && CurrentIndex >= _items.Count)
{
// The current track was removed and it was the last slot: there is no "next" occupant to
// resolve to, so the queue goes dormant (CurrentIndex == -1). Playback is NOT stopped
// (C2) — the just-removed track keeps playing to its natural end; auto-advance simply has
// nothing further. Removing current when it is NOT the last leaves CurrentIndex pointing
// at the new occupant of that slot (the next track), so no adjustment is needed there.
CurrentIndex = -1;
}
QueueChanged?.Invoke();
} }
public async Task Next() public async Task Next()
{ {
if (!HasNext) return; if (!HasNext) return;
CurrentIndex++; CurrentIndex++;
IsArmed = false;
QueueChanged?.Invoke(); QueueChanged?.Invoke();
await PlayCurrent(); await PlayCurrent();
} }
@@ -93,6 +187,7 @@ public sealed class QueueService : IQueueService, IDisposable
{ {
if (!HasPrevious) return; if (!HasPrevious) return;
CurrentIndex--; CurrentIndex--;
IsArmed = false;
QueueChanged?.Invoke(); QueueChanged?.Invoke();
await PlayCurrent(); await PlayCurrent();
} }
@@ -102,6 +197,21 @@ public sealed class QueueService : IQueueService, IDisposable
if (_items.Count == 0 && CurrentIndex == -1) return; if (_items.Count == 0 && CurrentIndex == -1) return;
_items.Clear(); _items.Clear();
CurrentIndex = -1; CurrentIndex = -1;
IsArmed = false;
QueueChanged?.Invoke();
}
public void ClearUpcoming()
{
// Keep the currently-playing track, drop everything else. No current track (dormant/empty) or a
// queue that already holds only the current → nothing to clear.
var current = Current;
if (current is null || _items.Count <= 1) return;
_items.Clear();
_items.Add(current);
CurrentIndex = 0;
// Playback is untouched (C2): the current track keeps streaming; we only discarded the up-next.
QueueChanged?.Invoke(); QueueChanged?.Invoke();
} }
@@ -0,0 +1,80 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
///
/// <para>
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
/// The beacon send is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from
/// the warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
/// </para>
/// </summary>
public sealed class ShareTracker
{
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
// recommendation — long enough to fold a flurry of repeat copies into one intent.
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
// change the integer enum encoding the 16.1 contract relies on.
private static readonly JsonSerializerOptions BeaconJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly BeaconInterop _beacon;
private readonly IAnonIdProvider _anonId;
private readonly string _shareUrl;
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
{
_beacon = beacon;
_anonId = anonId;
_shareUrl = $"{navigation.BaseUri}api/event/share";
}
/// <summary>
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
/// debounce without reaching into the beacon.
/// </summary>
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
/// <summary>
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
/// </summary>
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(targetKey))
return false;
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
return false;
_lastSent[dedupeKey] = now;
var json = JsonSerializer.Serialize(new ShareEventDto
{
TargetType = targetType,
TargetKey = targetKey,
Channel = channel,
AnonId = _anonId.Current,
}, BeaconJson);
// Fire-and-forget — a dropped share telemetry event is acceptable.
_ = _beacon.SendAsync(_shareUrl, json);
return true;
}
}
@@ -0,0 +1,22 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using NetBlocks.Models;
namespace DeepDrftPublic.Client.Services;
/// <summary>
/// <see cref="IStatsDataService"/> backed by <see cref="StatsClient"/> (HTTP to the <c>DeepDrft.API</c>
/// backend). Used on both the SSR prerender and WASM interactive passes — the stats read surface is
/// HTTP-only, so there is no separate in-process implementation.
/// </summary>
public class StatsClientDataService : IStatsDataService
{
private readonly StatsClient _statsClient;
public StatsClientDataService(StatsClient statsClient)
{
_statsClient = statsClient;
}
public Task<ApiResult<HomeStatsDto>> GetHomeStats() => _statsClient.GetHomeStats();
}
@@ -2,6 +2,7 @@ using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients; using DeepDrftPublic.Client.Clients;
using System.Buffers; using System.Buffers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace DeepDrftPublic.Client.Services; namespace DeepDrftPublic.Client.Services;
@@ -32,6 +33,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private readonly ILogger<StreamingAudioPlayerService> _logger; private readonly ILogger<StreamingAudioPlayerService> _logger;
private string? _currentTrackId; private string? _currentTrackId;
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
// most one bucketed play event per session, behind the engagement floor. Attached after construction
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
// constructor growth propagated through DI, no construction cycle. Null when telemetry is not wired
// (e.g. unit tests that construct the player without it), so every call is null-guarded.
private PlayTracker? _playTracker;
private BeaconInterop? _beacon;
private DotNetObjectReference<StreamingAudioPlayerService>? _unloadRef;
private string? _unloadKey;
// One-shot guard so the play session opens exactly once per LoadTrackStreaming — never on the
// SeekBeyondBuffer re-stream, which reuses _currentTrackId and re-runs the playback-start transition
// with _streamingPlaybackStarted reset. A seek-beyond-buffer is the SAME play (§1d), so it must not
// open a new session. Set true when the session opens; reset only by LoadTrackStreaming.
private bool _sessionOpened;
public StreamingAudioPlayerService( public StreamingAudioPlayerService(
AudioInteropService audioInterop, AudioInteropService audioInterop,
TrackMediaClient trackMediaClient, TrackMediaClient trackMediaClient,
@@ -41,6 +58,41 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
_logger = logger; _logger = logger;
} }
/// <summary>
/// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
/// §2.1). Called once by <c>AudioPlayerProvider</c>. Kept off the constructor deliberately: the player
/// is built with <c>new</c> by the provider (not DI), so threading the tracker through the constructor
/// would force the provider to resolve it too — instead the provider injects the tracker's collaborators
/// and hands a built tracker here, the same post-construction binding QueueService uses. Also registers
/// the page-unload handler so a mid-play tab-close still records the play via sendBeacon.
/// </summary>
public void AttachTracker(PlayTracker tracker, BeaconInterop beacon)
{
_playTracker = tracker;
_beacon = beacon;
_unloadRef = DotNetObjectReference.Create(this);
_unloadKey = PlayerId;
// Fire-and-forget: registration only needs to have happened before the listener leaves; it
// never gates playback. A failure simply means tab-close mid-play isn't recorded.
_ = _beacon.RegisterUnloadAsync(_unloadKey, _unloadRef, nameof(OnPageUnload));
}
/// <summary>
/// Close the open play session as the page unloads (pagehide / visibility→hidden). Invoked
/// synchronously from the beacon's unload handler so the session's beacon is queued before the page
/// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
/// </summary>
[JSInvokable]
public void OnPageUnload() => _playTracker?.Close();
// Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
// lowers it — the tracker takes the max.
protected override void OnProgressTick(double currentTime) => _playTracker?.OnProgress(currentTime);
// Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
protected override void OnPlaybackEnded() => _playTracker?.Close();
public override async Task SelectTrack(TrackDto track) public override async Task SelectTrack(TrackDto track)
{ {
await SelectTrackStreaming(track); await SelectTrackStreaming(track);
@@ -88,6 +140,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// Save track ID for seek operations // Save track ID for seek operations
_currentTrackId = track.EntryKey; _currentTrackId = track.EntryKey;
// A fresh load is a fresh play candidate (§1d: replays = multiple plays). Arm the
// one-shot session-open guard; the session actually opens at the playback-start transition
// below (a track that fails to load never reaches it, so it does not count).
_sessionOpened = false;
// Expose to UI immediately — Now-Playing surfaces should reflect the selected // Expose to UI immediately — Now-Playing surfaces should reflect the selected
// track while it's still loading, not only after playback starts. // track while it's still loading, not only after playback starts.
CurrentTrack = track; CurrentTrack = track;
@@ -303,6 +359,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{ {
Duration = chunkResult.Duration.Value; Duration = chunkResult.Duration.Value;
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration); _logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
// Feed the same once-only duration to the play session so it can compute the
// completion fraction at close. Safe before/after session open — SetDuration
// is a no-op when no session is open and idempotent otherwise.
_playTracker?.SetDuration(chunkResult.Duration.Value);
} }
// Start playback as soon as we can // Start playback as soon as we can
@@ -316,6 +376,20 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
IsPaused = false; IsPaused = false;
IsLoaded = true; // Track is loaded and ready to play (even if still downloading) IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
ErrorMessage = null; ErrorMessage = null;
// Open the play session exactly once per load, at the moment playback truly
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
// — which re-enters this transition with _streamingPlaybackStarted reset —
// from opening a second session for the same play. Duration may already be
// known from a prior chunk, so re-feed it after opening.
if (!_sessionOpened && _currentTrackId is { } trackKey)
{
_sessionOpened = true;
_playTracker?.OnPlaybackStarted(trackKey);
if (Duration is { } d)
_playTracker?.SetDuration(d);
}
await NotifyStateChanged(); // Immediate notification for critical state change await NotifyStateChanged(); // Immediate notification for critical state change
} }
else else
@@ -533,6 +607,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
/// </summary> /// </summary>
private async Task ResetToIdle() private async Task ResetToIdle()
{ {
// 0. Close any open play session BEFORE tearing down (§2.1). ResetToIdle is the single funnel
// for stop / unload / dispose / track-switch (a new LoadTrackStreaming calls it first), so a
// superseded listen is recorded here with its high-water bucket. Close is idempotent — if the
// session already closed organically or via the unload beacon, this is a no-op.
_playTracker?.Close();
_sessionOpened = false;
// 1. Cancel any ongoing streaming operation and wait for it to exit // 1. Cancel any ongoing streaming operation and wait for it to exit
// before tearing down JS state. Otherwise the loop's pending // before tearing down JS state. Otherwise the loop's pending
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync. // ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
@@ -630,12 +711,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{ {
try try
{ {
// ResetToIdle closes any open play session, so a dispose mid-play still records the listen.
await ResetToIdle(); await ResetToIdle();
} }
catch catch
{ {
// Disposal must not throw; any failure here is best-effort cleanup. // Disposal must not throw; any failure here is best-effort cleanup.
} }
// Detach the page-unload handler so the torn-down circuit is never invoked, then release the
// self-reference. Best-effort — the JS side tolerates an absent key.
if (_unloadKey is not null && _beacon is not null)
{
try { await _beacon.UnregisterUnloadAsync(_unloadKey); }
catch { /* best-effort */ }
}
_unloadRef?.Dispose();
_unloadRef = null;
await base.DisposeAsync(); await base.DisposeAsync();
} }
+16
View File
@@ -25,10 +25,26 @@ public static class Startup
services.AddScoped<IReleaseDataService, ReleaseClientDataService>(); services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
services.AddScoped<ReleaseDetailViewModel>(); services.AddScoped<ReleaseDetailViewModel>();
services.AddScoped<CutDetailViewModel>(); services.AddScoped<CutDetailViewModel>();
services.AddScoped<FramePlayerViewModel>();
// Home hero stats read surface — same HTTP posture as the track/release clients.
services.AddScoped<StatsClient>();
services.AddScoped<IStatsDataService, StatsClientDataService>();
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation // Waveform visualizer controls — scoped so the eight slider positions persist across navigation
// within a session and reset on a fresh page load (see WaveformVisualizerControlState). // within a session and reset on a fresh page load (see WaveformVisualizerControlState).
services.AddScoped<WaveformVisualizerControlState>(); services.AddScoped<WaveformVisualizerControlState>();
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
// share tracker fire events through it. The play tracker itself is NOT registered — the player
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
// is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider
// (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the
// session, warmed when a surface goes interactive (the player provider, the share popover).
services.AddScoped<BeaconInterop>();
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
services.AddScoped<ShareTracker>();
} }
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
@@ -0,0 +1,52 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
namespace DeepDrftPublic.Client.ViewModels;
/// <summary>
/// Resolves the ordered track list for a release embed (<c>FramePlayer?ReleaseEntryKey=...</c>). Mirrors
/// <see cref="CutDetailViewModel"/>'s release-to-tracks resolution exactly (GetByEntryKey -> release.Id
/// -> releaseId-filtered track page sorted by TrackNumber) so an embedded release queues the same
/// ordered list the Cut detail page plays. Owns no playback or staging — the page stages the first
/// track and arms the queue; this VM only fetches. Scoped; <see cref="Tracks"/> is reset per
/// <see cref="Load"/> so a reused instance never bleeds across embeds.
/// </summary>
public class FramePlayerViewModel
{
private readonly IReleaseDataService _releaseData;
private readonly ITrackDataService _trackData;
// One page covers the whole release; the API caps PageSize at 100 regardless. Matches CutDetailViewModel.
private const int ReleasePageSize = 100;
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
public FramePlayerViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
{
_releaseData = releaseData;
_trackData = trackData;
}
/// <summary>
/// Resolves <paramref name="releaseEntryKey"/> to its ordered tracks. Leaves <see cref="Tracks"/>
/// empty when the release is not found or has no streamable tracks — the caller leaves the bar idle.
/// </summary>
public async Task Load(string releaseEntryKey)
{
Tracks = [];
var releaseResult = await _releaseData.GetByEntryKey(releaseEntryKey);
if (releaseResult is not { Success: true, Value: { } release }) return;
// The release's tracks via the releaseId-filtered page — the exact join the Cut page uses
// (internal int FK, not a title string), sorted by the explicit TrackNumber ordinal so the
// queue advances in saved order.
var trackResult = await _trackData.GetPage(
pageNumber: 1,
pageSize: ReleasePageSize,
sortColumn: "TrackNumber",
releaseId: release.Id);
if (trackResult is { Success: true, Value: { Items: { } items } })
Tracks = items.ToList();
}
}
+2
View File
@@ -95,6 +95,8 @@ The proxy forwards public, unauthenticated routes:
- `GET api/track/genres` — distinct genres with counts - `GET api/track/genres` — distinct genres with counts
- `GET api/track/random` — random track selection - `GET api/track/random` — random track selection
- `GET api/track/meta/by-key/{entryKey}` — metadata lookup by vault entry key - `GET api/track/meta/by-key/{entryKey}` — metadata lookup by vault entry key
- `POST api/event/play` — anonymous play-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
- `POST api/event/share` — anonymous share-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
All actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent. All actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent.
+3 -1
View File
@@ -14,7 +14,7 @@
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/css/parallax.css"]" /> <link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/css/parallax.css"]" />
<link rel="stylesheet" href="@Assets["styles/deepdrft-styles.css"]" /> <link rel="stylesheet" href="@Assets["styles/deepdrft-styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" /> <link rel="icon" type="image/ico" href="deepdrft-logo-bw.ico" />
<HeadOutlet @rendermode="InteractiveAuto" /> <HeadOutlet @rendermode="InteractiveAuto" />
</head> </head>
@@ -25,6 +25,8 @@
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script> <script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
<script type="module"> <script type="module">
import('./js/audio/index.js'); import('./js/audio/index.js');
import('./js/telemetry/beacon.js');
import('./js/telemetry/anonid.js');
</script> </script>
</body> </body>
</html> </html>
@@ -0,0 +1,87 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
namespace DeepDrftPublic.Controllers;
/// <summary>
/// Proxies the anonymous telemetry write endpoints (<c>POST api/event/play</c> / <c>api/event/share</c>)
/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors
/// <see cref="TrackProxyController"/>'s idiom — the named <c>"DeepDrft.API"</c> client forwards the
/// request upstream — but for a POST write: the small JSON body is buffered and relayed verbatim, and
/// the upstream status (202 on success, 4xx on a rejected payload, 429 on rate limit) passes back so the
/// beacon's fire-and-forget contract is preserved end to end. SSR never posts these — they originate
/// from the browser player/share surfaces only.
/// </summary>
// A sendBeacon POST cannot attach a Blazor antiforgery token, so the telemetry write routes opt out
// explicitly. They are anonymous, idempotent-enough fire-and-forget logging — there is no
// state-changing user action to protect with CSRF tokens, and the upstream rate-limits by IP.
[ApiController]
[Route("api/event")]
[IgnoreAntiforgeryToken]
public class EventProxyController : ControllerBase
{
private readonly HttpClient _upstream;
private readonly ILogger<EventProxyController> _logger;
public EventProxyController(IHttpClientFactory httpClientFactory, ILogger<EventProxyController> logger)
{
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
_logger = logger;
}
/// <summary>Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here.</summary>
[HttpPost("play")]
public Task<ActionResult> ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct);
/// <summary>Proxies a share event upstream.</summary>
[HttpPost("share")]
public Task<ActionResult> ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct);
private async Task<ActionResult> Forward(string upstreamPath, CancellationToken ct)
{
// Buffer the small JSON body and relay it verbatim. Reading the raw body keeps the proxy
// transparent — it does not deserialize or re-shape the payload, just forwards it.
string body;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
{
body = await reader.ReadToEndAsync(ct);
}
using var request = new HttpRequestMessage(HttpMethod.Post, upstreamPath)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
// Forward the real client IP so DeepDrftAPI's per-IP rate limiter (Program.cs "events" policy)
// partitions on individual listeners rather than the proxy host. Standard XFF chaining: relay
// any inbound X-Forwarded-For from an upstream proxy (nginx), then append the connection IP
// of the current hop (the browser → public host connection). DeepDrftAPI calls
// UseForwardedHeaders() in production, which resolves the leftmost untrusted value in the
// chain into Connection.RemoteIpAddress — which the rate limiter then keys on.
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
if (clientIp is not null)
{
var existing = Request.Headers["X-Forwarded-For"].ToString();
var xff = string.IsNullOrEmpty(existing) ? clientIp : $"{existing}, {clientIp}";
request.Headers.TryAddWithoutValidation("X-Forwarded-For", xff);
}
HttpResponseMessage upstream;
try
{
upstream = await _upstream.SendAsync(request, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Path} failed", upstreamPath);
return StatusCode(502, "Upstream unavailable");
}
// Relay the upstream status as-is. Telemetry is fire-and-forget; the beacon never reads the
// body, so there is nothing to relay beyond the code (202 / 400 / 429 / 5xx).
using (upstream)
{
return StatusCode((int)upstream.StatusCode);
}
}
}

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