Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbd298b9c3 | |||
| 3da6591194 | |||
| da60296cf8 | |||
| 4320ea8029 | |||
| 678d3f66ad | |||
| be04e53a97 | |||
| 58b30d3c13 | |||
| be1a55fd37 | |||
| 9d0ce99a5d | |||
| 1d387c2a34 | |||
| fe3819f378 | |||
| cfcc2693f2 | |||
| 621c4f9cb3 | |||
| 67eeb38529 | |||
| 9aa66e8a62 | |||
| 3b9ca700c9 | |||
| 4317a2f9e7 | |||
| 297805b5a8 | |||
| 944f23a88c | |||
| 75e5d99aea | |||
| c084efa78e | |||
| f296bbdf00 | |||
| ebbaa3f84f | |||
| a715f4b28d | |||
| 90555dc4e0 | |||
| 0fbf81b23e | |||
| 4114aa0be4 | |||
| 884ccab826 | |||
| 3c1998de4f | |||
| 622ee940f4 | |||
| 18e171213c | |||
| e9c61bac1a | |||
| dbd90ee52a | |||
| 1b7861e168 | |||
| 098020db32 | |||
| 912256d99a | |||
| 1931574ad4 | |||
| 25aba1cbb7 | |||
| 81d0028f2b | |||
| 62007a6517 | |||
| 13b07beb0b | |||
| 7711c5067c | |||
| eaa71ebea3 | |||
| e8359d5473 | |||
| 7265754c27 | |||
| abc832467d | |||
| 47919a226e | |||
| 933b7145e5 | |||
| f21647423a | |||
| df7acd9e80 | |||
| 3a4db834ac | |||
| d12151278a | |||
| ca90302f21 | |||
| 16784b37f2 | |||
| e9e6b6054f | |||
| 8fa330fbd3 | |||
| 5f0422a263 | |||
| 8ddecb4acc | |||
| 17a35247c1 | |||
| fb987acc18 | |||
| b524b8e6ec | |||
| 9cfc31f725 | |||
| d512a1d329 | |||
| 4e2033e40c | |||
| 8c811c411c | |||
| 44c17c8b73 | |||
| d961eadc93 | |||
| c7d627b817 | |||
| 9850be8a49 | |||
| f49e196596 | |||
| c8168564bb | |||
| a210b2ded7 | |||
| 7386ab0dd0 | |||
| 3f83e0f11c | |||
| 6303b4f62c | |||
| 02cc83ed31 | |||
| a97cdcf395 | |||
| 5614bbefad | |||
| 6ecc7f1f37 | |||
| 35ae775954 | |||
| 412b96ba16 | |||
| 40b5cb8328 | |||
| 7e27856359 | |||
| 2c5c569797 | |||
| 855a4a5d2a | |||
| 3835d9f9c4 | |||
| 8a329aadcf | |||
| e2c3f2a3aa | |||
| b16fc3ca7e | |||
| 282cafc52f | |||
| 08f56d09d1 | |||
| e4b6fc525f | |||
| 53a27ce06c | |||
| fc32791cea | |||
| 007033e7e8 | |||
| e38678009e | |||
| 1fef60a7fb | |||
| 29ab4840d0 | |||
| 15ddc4c332 | |||
| 2c2342fbaf | |||
| b8f81edb59 | |||
| db8391b81c | |||
| db29b0dd18 | |||
| dd4f8ddded | |||
| 23a1275025 | |||
| 13fbcc2d43 | |||
| fe481d0417 | |||
| ded5dca698 | |||
| 167b2fc3c5 | |||
| 2071a821db | |||
| 6f00c6fa54 | |||
| 43bbc8172b | |||
| 30999726e5 | |||
| 826ce218a4 | |||
| 739d6c6e81 | |||
| d12b732e40 | |||
| e24048e961 | |||
| 528f09d96a | |||
| 0dce46bcab | |||
| f00758dc47 | |||
| 8a187a3ed8 | |||
| 9395f503b4 | |||
| bc804afb55 | |||
| 80220d06f0 | |||
| 05486a61af | |||
| 955182d6da | |||
| 5fb46bf5eb | |||
| 9009f2c8cf | |||
| f1afe6e028 | |||
| 7a3d44420a | |||
| 4477026638 | |||
| 9f8808a596 | |||
| b501cd9e3e | |||
| 803bc7840a | |||
| 7aeced6a8d | |||
| a19a734757 | |||
| c9c6286571 | |||
| ec3989c354 | |||
| 916bf626de | |||
| 3eef1a50f9 | |||
| 585dd30efb | |||
| accf20ba57 | |||
| 3839948eeb | |||
| dc70be768a | |||
| ad94354632 | |||
| 8331ccf6a3 | |||
| 372e006be1 | |||
| dcfb1fca9f | |||
| ea74aaaf2e | |||
| 54ef4c038e | |||
| 394b07f404 | |||
| d6df0de63a | |||
| 76060f60a8 | |||
| b9f06bb7cd | |||
| d105385006 | |||
| e48baa5b27 | |||
| cf47fee07e | |||
| a0b3255028 | |||
| d36aea212c | |||
| efef23753b | |||
| 4e34696719 | |||
| ba1a1cd8ec | |||
| bfdbf7568f | |||
| 4eba3b0bb3 | |||
| 39fabc8d0d | |||
| 371812b274 | |||
| a6d25344b4 | |||
| 81ea5909d2 | |||
| 9cf6bb4cf2 | |||
| 3e97e34aee | |||
| fc7c9e978f | |||
| daafae8af6 | |||
| 841822d8fe | |||
| 1730aa0166 | |||
| b7a60f24c5 | |||
| f7366b167c | |||
| c926937694 | |||
| d8d908d4a6 | |||
| b5fdb826b0 | |||
| dae8020a22 | |||
| 919a800f4b | |||
| 79de2503c4 | |||
| 5b3036ed83 | |||
| 946b1d7cf9 | |||
| 56d94b7424 | |||
| 41ac7a5a93 | |||
| f07d29cdcf | |||
| bb4e169d0a | |||
| fe28573b68 | |||
| d5ea5f52ee | |||
| 78df665480 | |||
| a64a5598ae | |||
| 5fb7d85019 | |||
| ca5fc5649a | |||
| 09309630cb | |||
| db7afe4ea7 | |||
| e6a80b6086 | |||
| f35cbc82fe | |||
| ed7304af1f | |||
| 0b2fee1520 | |||
| cff18df783 | |||
| 2c4bd3a394 | |||
| d899bc9456 | |||
| ce437521ee | |||
| ef6d21b94e | |||
| bef1e3adfb | |||
| 313551ac7c | |||
| f08b412772 | |||
| d98ead97c3 | |||
| ff37efea89 | |||
| 55515981a9 | |||
| 74b9c02722 | |||
| 96b13af95d | |||
| f8f9844ef4 | |||
| 6ac943ca09 | |||
| 364450885b | |||
| fbb397228e | |||
| c2a3e53991 | |||
| 23b34004ff | |||
| aedbe82d28 | |||
| 2bb7d86e63 | |||
| ff9c87c461 | |||
| e59271aa00 | |||
| b27ec1b7d0 | |||
| c1ed2a9ba3 | |||
| 294414d00a | |||
| 2b42e01cd0 | |||
| 26d7a05ba4 | |||
| cfacc9f79a | |||
| 07ddc69cee | |||
| 779e1f569c | |||
| 5011fb43f0 | |||
| a9d6445881 | |||
| 56e205082d | |||
| 31e00e6abd | |||
| e9f4411fdf | |||
| 22c2ae5ecb | |||
| b7bd6ba04f | |||
| 1e6129401b | |||
| bf00b7f22f | |||
| 913861860b | |||
| e0f371cda6 | |||
| 44a15bf67d | |||
| 65e5e09245 | |||
| d73e94a12f | |||
| df4381b4d8 | |||
| ad8cb7dbc0 | |||
| 652c90979d | |||
| 1ad501ff11 | |||
| c9b8dfcf3f | |||
| 2bacf58241 | |||
| 83c0425133 | |||
| 0758bfe7f1 | |||
| 79e7bb4799 | |||
| 45bf5e5d37 | |||
| 3c7f28b2eb | |||
| 61d53dacff | |||
| 06b58304c5 | |||
| b3283d0bd2 | |||
| cb6f75be5f | |||
| c1562dde03 | |||
| 8b0bd6d26e | |||
| 7d23c0654b | |||
| cab181db4b | |||
| f02f370ed9 | |||
| b451dda79e | |||
| 4f84216ab6 | |||
| bb50d8369b | |||
| ebdcc29f2e | |||
| ea8b97e47b | |||
| f1600023dc | |||
| 09f6dc88f7 | |||
| 31084b09a4 | |||
| 5941f1f23a | |||
| 7f1c6bdb66 | |||
| 37608aee28 | |||
| e0ab2f3d00 | |||
| 41e3ccc9fa | |||
| 709103ad71 | |||
| 9f074f7350 | |||
| 47082591ee | |||
| 4df2b8fb57 | |||
| a9965ad751 | |||
| c64455f2f2 | |||
| 2d0a565765 | |||
| c608fa345a | |||
| 09a980ba2a | |||
| da08ac4efb | |||
| 00d7215178 | |||
| 898fcfaa04 |
@@ -9,3 +9,4 @@
|
||||
*.conf text eol=lf
|
||||
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout
|
||||
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
|
||||
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
|
||||
|
||||
+2
-1
@@ -317,4 +317,5 @@ Database/Vaults/*
|
||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
|
||||
# gitignored TS output is absent when manifest is generated, so absent from publish output.
|
||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
||||
!DeepDrftShared.Client/wwwroot/js/knob/
|
||||
@@ -9,11 +9,11 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
### Core Projects
|
||||
|
||||
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles, release-track join operations. Release endpoints: paged list with medium filter, single read, mix waveform compute, 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.
|
||||
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
|
||||
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
|
||||
@@ -34,7 +34,7 @@ Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server,
|
||||
|
||||
1. **SQL Database (PostgreSQL)**: Metadata and track info via Entity Framework
|
||||
- Connection string: Read from `environment/connections.json` via `CredentialTools.ResolvePathOrThrow("connections")` with key `ConnectionStrings:DefaultConnection`.
|
||||
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`
|
||||
- Entity: `TrackEntity` with `Id`, `EntryKey`, `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?`, `DurationSeconds?`
|
||||
- Context: `DeepDrftContext` in `DeepDrftData`
|
||||
|
||||
2. **FileDatabase**: Custom file-based binary storage system
|
||||
@@ -80,11 +80,13 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
|
||||
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
|
||||
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
|
||||
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
|
||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DDIcons.cs`.
|
||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
|
||||
|
||||
### TypeScript interop, not raw JS
|
||||
|
||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging.
|
||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
|
||||
|
||||
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -112,10 +114,10 @@ dotnet run --project DeepDrftAPI
|
||||
### Entity Framework (SQL Database)
|
||||
```bash
|
||||
# Add migration (from solution root)
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
|
||||
|
||||
# Update database
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
|
||||
```
|
||||
|
||||
## Key Configuration Files
|
||||
@@ -123,7 +125,7 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftPubli
|
||||
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
|
||||
|
||||
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`).
|
||||
- `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).
|
||||
|
||||
## Folder-Level Guidance
|
||||
|
||||
+387
-3
@@ -6,6 +6,370 @@ 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`**: T1–T10 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` 30–80%, `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` (30–80%), `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, track–release join) to serve real numbers in a single efficient query.
|
||||
|
||||
- **Shape:**
|
||||
- **New SQL column:** `DurationSeconds` (`double?`, column `duration_seconds`) on `TrackEntity` and `TrackDto`. Populated at upload via the existing dual-database add flow (`TrackContentService` extracts duration from vault audio; `UnifiedTrackService` persists it to SQL). Migration `20260618155002_AddTrackDuration`. Configured in `TrackConfiguration`.
|
||||
- **New aggregate query:** `TrackRepository.GetHomeStatsAsync` → `HomeStatsDto` (new DTO in `DeepDrftModels/DTOs/`). Returns cut track count, per-`ReleaseType` cut release counts (zero-count types suppressed), mix release count, and total mix runtime seconds (null durations counted as 0; tracks under soft-deleted releases excluded). Surfaced via `ITrackService.GetHomeStats` on `TrackManager`.
|
||||
- **New API endpoints:** `GET api/stats/home` (`StatsController`, unauthenticated; returns `HomeStatsDto` bare) and `POST api/track/duration/backfill` (ApiKey-gated; one-time backfill of `DurationSeconds` for pre-existing rows from vault audio, delegated to `UnifiedTrackService.BackfillDurationsAsync`).
|
||||
- **New public proxy:** `StatsProxyController` in `DeepDrftPublic` mirrors `ReleaseProxyController`; forwards `GET api/stats/home` from the browser to DeepDrftAPI.
|
||||
- **New client surface:** `StatsClient` (`Clients/`, named `"DeepDrft.API"` client) + `IStatsDataService` / `StatsClientDataService` (`Services/`) registered scoped in `Startup.ConfigureDomainServices`. `RuntimeFormat` static helper (`Helpers/`) converts seconds to `hh:mm`.
|
||||
- **`NowPlayingStats.razor`:** now renders live data — Studio Cuts card (cut track count + zero-suppressed Single/EP/Album breakdown), Mixes card (`MixReleaseCount` "Sets" + `hh:mm` runtime), Plays card (static "XXX / Coming Soon" odometer placeholder). Uses `PersistentComponentState` to bridge the SSR prerender fetch across the WASM seam (only persists on a successful load).
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — About Page (public site editorial) (landed 2026-06-17)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** A real About page for the public site (`/about`), built entirely in the **Home page's existing visual language** — no new look. Three movements — **the People**, **the Process**, **the Product** — with ethos / pathos / logos woven through the prose as registers, not labelled blocks. The strategic frame (Daniel): the site is *presentation and proof of effort* — evidence that real people are pushing the classic club sound forward; the About page is where that claim is made explicit. Built as `DeepDrftPublic.Client/Pages/About.razor` + scoped `About.razor.css`; registered in the nav index (`DeepDrftPublic.Client/Layout/Pages.cs`). Images served statically from `DeepDrftPublic/wwwroot/img/`; image slots and Khabran's bio degrade gracefully until final assets/copy land.
|
||||
|
||||
- **Why:** This is its own phase, not a graft onto Phase 11: Phase 11 was structural (release-cardinal browse, queue, GUID handles), whereas this is a net-new **editorial** surface. The page reuses Home's section primitives wholesale (`.hero`, `.section-divider`, two-column `.section`, dark `.section-dark` feature band, `.section-split`, `.cta-banner`, `ParallaxImage` full-bleed bands) — no new visual language introduced; the only candidate new styling is two member-bio cards, assembled from existing type tokens. Full spec: `product-notes/about-page.md`.
|
||||
|
||||
- **Shape:** `DeepDrftPublic.Client/Pages/About.razor` (new; `@page "/about"`; three-movement editorial page using Home section primitives); `DeepDrftPublic.Client/Pages/About.razor.css` (new; scoped styles — Home section primitives currently re-declared here rather than shared globally, a known follow-up); `DeepDrftPublic.Client/Layout/Pages.cs` (nav index registration added). Static images from `DeepDrftPublic/wwwroot/img/`.
|
||||
|
||||
**Voice constraint (hard):** smart, serious, no AI-isms — underground Detroit/Midwest deep-club-house heritage carried to Charleston. All body prose remains DRAFT pending Daniel's approval — section headers and UI labels are set; any sentence/paragraph of site copy is a placeholder until Daniel passes it.
|
||||
|
||||
**Open follow-ups:** (1) ~~Final photo files for the five image slots~~ Photo slots largely resolved: bio portraits (Khabran + Daniel) now wired as circular framed portraits with a bw→colour hover crossfade (`dd-khabran`, `dd-daniel`); Process hands-on-gear figure now uses bespoke `dd-mixer-2`; the Liner Notes redesign removed the separate full-bleed atmosphere and closing-band slots, so the duo hero (`dd-duo-2`) and the hands-on-gear inset are the only full-bleed image positions remaining and both carry bespoke assets. Home page "Our Origin" split swapped the retired `kp-shoulder` for `dd-pedals`. (2) ~~Khabran's bio text still open~~ Khabran's bio is now wired (three paragraphs; bio-body render updated to emit each paragraph as its own `<p class="bio-body">` — Daniel's single-paragraph bio is unaffected). (3) Optional promotion of the duplicated Home section primitives from `About.razor.css` to a shared global stylesheet. (4) ~~Whether CUTS/SESSIONS/MIXES are explained on the page~~ Resolved by the Liner Notes redesign: the triptych renders as a stacked editorial definition list (see Redesign addendum below).
|
||||
|
||||
**Redesign — Liner Notes editorial treatment (2026-06-17):** The page was rebuilt from the Home section primitives approach into a distinct **"Liner Notes"** editorial layout. Structure and copy (three-movement People / Process / Product) are unchanged; the visual treatment is entirely new. Key elements: numbered left rail (oversized Bodoni 01/02/03 movement numerals + continuous vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking left into the margin, hand-authored SVG waveform movement dividers (a self-contained decorative motif, not the live `WaveformVisualizer` component). The CUTS/SESSIONS/MIXES triptych is now a stacked editorial definition list rather than Home's medium-card image grid. Active-movement highlight on the left rail is progressive enhancement via a new `DeepDrftPublic/Interop/about/about-rail.ts` IntersectionObserver interop (compiled output gitignored). Superseded Home section primitives were removed from `About.razor.css`; the global stylesheet was untouched. Design authority: `product-notes/about-page-distinction.md` (Direction 1).
|
||||
|
||||
---
|
||||
|
||||
## Phase 15 — Visualizer Controls Enhancements (landed 2026-06-17)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** A presentation and interaction rework of the waveform visualizer control surface — the eight-RadialKnob panel (Phase 12) hosted by `WaveformVisualizerControlPopover`. Not a renderer change: the WebGL2 visualizer, the eight continuous dial values + their defaults, and the `Changed`-event bridge seam are unchanged. The phase reworks how the controls are reached and presented, adds two on/off toggles (lava, waveform), and gives the panel a deterministic, sectioned layout that encodes the visualizer's composition (lava field + waveform ribbon, optionally overlaid).
|
||||
|
||||
Four tracks shipped as a single bundled PR (`15.A → {15.B, 15.C} → 15.D`):
|
||||
|
||||
- **15.A — State booleans + bridge wiring.** Two new `WaveformVisualizerControlState` booleans: `LavaEnabled` and `WaveformEnabled` (both default `true`). `WaveformVisualizer.ts` gained a genuine per-subsystem draw-skip: when a subsystem is "off" it is not drawn, contributes no collisions, and incurs no render cost (not dimmed). The bridge pushes the new booleans on `Changed` alongside the eight existing dials. The per-subsystem draw-skip seam was built as part of this track (it did not exist prior).
|
||||
- **15.B — Screen-centered tinted-modal primitive + NowPlayingCard chrome.** `WaveformVisualizerControlPopover` changed from an anchored `MudPopover` to a screen-centered, tinted modal `MudOverlay` (`DarkBackground`, `Modal="true"`). The `AnchorOrigin`/`TransformOrigin` parameters were dropped — a centered modal has no anchor. Panel chrome follows the NowPlayingCard look: square corners, lighter-navy ground, thin light border. Chrome classes stay in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). Tint opacity resolves from a single `--deepdrft-modal-scrim-alpha` token. Knob-drag safety is preserved: `RadialKnob` mounts its own `position:fixed` capture div above the scrim while dragging, so releasing outside the panel does not close the modal.
|
||||
- **15.C — Deterministic three-row layout + toggles + scroll slider.** The flat eight-knob grid replaced by a three-row sectioned layout: **Row 1 (MODE, always visible):** two lamp toggles (lava / waveform) left-aligned + collisions knob (only when both subsystems on) + color knob pinned far-right. **Row 2 (LAVA, visible only when lava on):** "LAVA:" label + Gravity / Heat / FluidAmount / FluidViscosity knobs. **Row 3 (WAVE, visible only when waveform on):** "WAVE:" label + scroll/zoom `MudSlider` (bound to `ScrollSpeed` alone) + width knob pinned far-right. The lamp toggles use the `DDIcons.LavaLamp` / `DDIcons.LavaLampFilled` glyph (lit = on, unlit = off) and are green (`Color.Primary`) because they are interactive.
|
||||
- **15.D — Tooltips + light icon colour.** Each control received a playful, non-technical `MudTooltip`. Knob caption icons and section labels changed to light (`Color.Default` / CSS light token) per the resolved colour principle: green = interactive elements (toggles, knob arcs/pointers, scroll slider); light = static/decorative elements (section labels, caption icons).
|
||||
|
||||
- **Why:** The eight-knob flat grid gave the user no signal about which knobs drive the lava vs. the waveform, and neither subsystem could be turned off independently. The new layout sections controls by subsystem, making "lava only" / "waveform only" first-class operating modes. The screen-centering solves the anchored-popover problem: `MudPopover` positions off its trigger's bounding rect — wrong for a control panel that should read as centered regardless of where the lava-lamp icon sits (Mix corner, Cut/Session ambient, NowPlaying corner).
|
||||
|
||||
- **Shape:** `DeepDrftPublic.Client/Controls/WaveformVisualizerControls.razor` — three-row layout replacing the flat eight-knob grid; two `ToggleLava`/`ToggleWaveform` handlers; conditional row visibility; `MudSlider` for scroll speed. `DeepDrftPublic.Client/Controls/WaveformVisualizerControlPopover.razor` — `MudPopover` replaced by `MudOverlay` (centered, `DarkBackground`, `Modal`); `AnchorOrigin`/`TransformOrigin` parameters removed. `DeepDrftPublic.Client/Services/WaveformVisualizerControlState.cs` — two new boolean properties (`LavaEnabled`, `WaveformEnabled`) and matching `DefaultLavaEnabled`/`DefaultWaveformEnabled` consts (both `true`). `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — per-subsystem draw-skip seam (lava physics + blob uploads skipped when lava off; ribbon SDF + collision boundary dropped when waveform off). `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` — `--deepdrft-modal-scrim-alpha` token; `.waveform-visualizer-control-overlay` centering; `.waveform-visualizer-control-modal` panel chrome (square corners, lighter-navy, thin border); row/section layout classes (`wvc-row`, `wvc-row-mode`, `wvc-row-section`, `wvc-row-wave`, `wvc-section-label`, `wvc-toggle`, `wvc-slider`). Full design, layout contract, primitive rationale, tooltip copy, and acceptance: `product-notes/phase-15-visualizer-controls-enhancements.md`.
|
||||
|
||||
**Post-landing fixes (2026-06-17):** Seven defects found during smoke-testing were remediated in a follow-up round on dev: (1) new `--deepdrft-panel-ground` CSS token so the blue slider reads against the panel background; (2) drag-scrollbar removed + body-scroll locked while the modal is open; (3) knob caption icons forced light so lamp toggles stay green; (4) WAVE-row slider vertically centered; (5) **site-wide `RadialKnob` pointer-capture fix** — drag no longer sticks when the cursor leaves the browser window, implemented via real `setPointerCapture` / `releasePointerCapture` (benefits every `RadialKnob` on the site, not just this panel); (6) modal scrim alpha softened (0.3 → 0.15); (7) modal overlay z-index raised above the header and player-dock footer. Fix #5 introduced a **new TypeScript interop module in `DeepDrftShared.Client`**: `Interop/knob/knob.ts` (exports `capturePointer`/`releasePointer`), compiled to `wwwroot/js/knob/knob.js` via `Microsoft.TypeScript.MSBuild`, lazy-imported by `RadialKnob.razor` as `_content/DeepDrftShared.Client/js/knob/knob.js` — following the existing `parallax.ts` precedent in the same RCL.
|
||||
|
||||
**Polish round 2 (2026-06-17):** Five further UI changes from Daniel's second review: (1) panel ground darkened further (`--deepdrft-panel-ground` `#1e2028` → `#1a1c22`); (2) **WAVE-row scroll/zoom control reverted from `MudSlider` back to a `RadialKnob`** — Daniel's explicit call, reversing the §8 slider decision; the scroll control is now a knob like the other dials; (3) **waveform toggle given its own distinct icon** — new `DDIcons.Waveform`/`WaveformFilled` six-bar sound-wave glyph, so the waveform toggle and lava toggle each have a unique visual identity (lava toggle keeps the lamp); (4) **strong active-state styling on both toggles** — green-accent filled chip + ring when ON, dimmed when OFF, making subsystem state unmistakable at a glance; (5) `WaveformVisualizerControlPopover.razor` in-source comment refreshed to describe the `setPointerCapture` mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Phase 14 — CMS Releases Consolidation (landed 2026-06-17)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** Retired the CMS `/tracks` list view and consolidated all release browsing into a new standalone **`/releases`** page (`DeepDrftManager/Components/Pages/Tracks/Releases.razor`). The TRACKS|RELEASES `BrowseMode` toggle is gone. The `/releases` layout is: bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid. The unique per-track waveform-status columns (Profile / High-res, with per-row generate buttons) and the per-track info tooltip (EntryKey + OriginalFileName) now live in `CmsAlbumBrowser`'s expanded child-row track table; page-level bulk runs and per-row generates share a refresh bridge (`InvalidateWaveformStatusAsync` + `OnWaveformGenerated` EventCallback wired through each medium container). The `/catalogue` dashboard cards changed from Tracks / Releases / Genres to **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; `/tracks/genres` was removed. Operational sub-routes (`/tracks/upload`, edit routes, `/tracks/mixes`, `/tracks/sessions`, etc.) stayed at `/tracks/*`. `ICmsTrackService.GetGenreSummariesAsync` removed (dead interface member). `GetTrackCountAsync` intentionally retained — planned for the public-site NowPlayingStats feature.
|
||||
- **Why:** The `/tracks` page mixed a list view and a releases browser behind a toggle (`BrowseMode`), and the waveform-status columns cluttered a per-track list that had no natural home once releases became the cardinal browse unit. Consolidating into a dedicated `/releases` page with a medium tab strip matches the release-medium mental model established in Phase 9 and makes waveform management a subordinate detail of the release's expanded track table rather than a top-level grid column. Retiring genre browse removes a dead-end CMS surface (genre is a filter, not a first-class browse dimension for the admin).
|
||||
- **Shape:** New: `DeepDrftManager/Components/Pages/Tracks/Releases.razor` (`@page "/releases"` + alias routes for `/tracks`, `/tracks/albums`, `/tracks/archive`). Deleted: `TrackList.razor`, `CmsTrackGrid.razor` (+ `.css`), `CmsGenreBrowser.razor` (+ `.css`), `Services/CmsTrackBrowserViewModel.cs` (+ its DI registration in `Program.cs`). Changed: `Index.razor` dashboard cards updated to CUTS / SESSIONS / MIXES deep-linking to `/releases?medium=<medium>`; `CmsAlbumBrowser` expanded child-row track table gains waveform-status columns + info tooltip + `OnWaveformGenerated` EventCallback; `ICmsTrackService` / `CmsTrackService` — `GetGenreSummariesAsync` removed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 13 — CMS Public Landing (landed 2026-06-17)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** Gave `DeepDrftManager` (the CMS) a true public face: an unauthenticated splash at `/` with DeepDrft branding and a single **Login** CTA; authenticated admins are redirected past it to the catalogue. Previously `/` was the `[Authorize]`-gated catalogue dashboard, so an anonymous hit fell straight through to the login form with no front door. Pattern borrowed from the `MainHomeLayout` / `Home.razor` idiom (dedicated public layout + `HierarchicalRoleAuthorizeView` redirect-the-authed-user), branded to the DeepDrft navy/green/off-white identity (`DeepDrftPalettes.Cms`). Additive — the admin experience is intact; only the catalogue's route moved. Routing decision: **Option A — splash owns `/`, catalogue moves to `/catalogue`** (Options B and C were weighed and rejected). New files: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, `CmsHomeLayout`) wraps its body in `<HierarchicalRoleAuthorizeView>`: `Authorized` → `<RedirectToCatalogue />`; `NotAuthorized` → hero (`img/cms-hero.png`) + Login CTA (returnUrl → `/catalogue`). `Components/Layout/CmsHomeLayout.razor` — lean public layout (`DeepDrftPalettes.Cms` theme, "Deep Drft — Admin" AppBar, centered narrow `MudContainer`, `MudPopoverProvider` only). `Components/RedirectToCatalogue.razor` — inline `NavigationManager.NavigateTo("/catalogue")` redirect, mirroring `RedirectToAccessDenied`. Changed: `Index.razor` route `@page "/"` → `@page "/catalogue"`; `CmsLayout.razor` "Back to site" home button `Href` and tooltip updated to `/catalogue` / "Catalogue". AppBar wording resolved: "Deep Drft — Admin" in the bar, "Deep Drft" as the hero title.
|
||||
- **Why:** An anonymous visitor hitting the CMS root landed directly on the AuthBlocks login form with no DeepDrft context, branding, or explanation. The splash provides a proper front door while keeping the admin surface fully intact.
|
||||
- **Shape:** `DeepDrftManager/Components/Pages/Home.razor` (new); `DeepDrftManager/Components/Layout/CmsHomeLayout.razor` (new); `DeepDrftManager/Components/RedirectToCatalogue.razor` (new); `DeepDrftManager/Components/Pages/Index.razor` (route changed to `/catalogue`); `DeepDrftManager/Components/Layout/CmsLayout.razor` (home-button href + tooltip updated). Hero asset: `DeepDrftManager/wwwroot/img/cms-hero.png` (Daniel-supplied; page compiles and renders without it). Full spec: `product-notes/cms-public-landing.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Waveform Visualizer Generalization + NowPlayingHero Rewire (all tracks landed 2026-06-17)
|
||||
|
||||
**Landed:** 2026-06-17 on dev. Six tracks (12.A, 12.B1, 12.B2, 12.E, 12.C, 12.D) plus a bridge live-track fix, all merged.
|
||||
|
||||
- **What:** Took the landed Mix WebGL2 lava visualizer (Phase 10 reframe) and made it the one track-cardinal visualizer — serving Mix detail, all Release Detail pages, and the home-page NowPlaying card — rendering the waveform of whatever track is currently playing/selected. Two deliverables: (1) the generalized engine serving three hosting modes, (2) the NowPlayingHero rewire. Full design, extraction analysis, per-track model, Direction B compute, wave decomposition: `product-notes/phase-12-waveform-visualizer-generalization.md`.
|
||||
|
||||
- **12.A — Rename to the abstraction.** `MixWaveformVisualizer` → `WaveformVisualizer`, `MixVisualizerControls` → `WaveformVisualizerControls`, `MixVisualizerControlState` → `WaveformVisualizerControlState`, `MixZoomMapping` → `WaveformZoomMapping`, `MixVisualizer.ts` → `WaveformVisualizer.ts`. Mechanical rename across the five C#/Razor files + TS module + import path + DI registration. No behavior change; Mix detail identical after.
|
||||
- **12.B1 — Generalize high-res compute to every track + backfill (Direction B).** `MixWaveformResolution` → `WaveformResolution`. Vault `mix-waveforms` → `track-waveforms` (`VaultConstants.TrackWaveforms`), keyed per-track by `EntryKey`. New `WaveformProfileService.ComputeAndStoreHighResAsync` is the shared compute seam — upload path, CMS generate action, and Mix trigger all funnel through it. `UnifiedTrackService.UploadAsync` now computes the high-res datum for every new track. CMS generate action generalized to any track; a re-runnable "backfill high-res" batch action added in the CMS `TrackList`. `WaveformStatusDto.HasHighRes` added alongside the existing `HasProfile`. Backfill is Daniel-gated (CMS batch action; fetch 404s gracefully for not-yet-backfilled tracks).
|
||||
- **12.B2 — Per-track datum fetch + bridge rewire.** New track-cardinal endpoint `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + public proxy; `ITrackDataService.GetTrackWaveform`; bridge resolves the current track's `EntryKey` and re-fetches on track change. Client `GetMixWaveform` read path retired; API-side release waveform endpoint kept as a caller-less legacy delegate. Mix renders the same high-res lava via the track-cardinal fetch.
|
||||
- **12.E — Popover-hosted control panel.** `WaveformVisualizerControls` became the panel content; new `WaveformVisualizerControlPopover` pairs the lava-lamp icon with the panel as overlay content (`MudPopover`). Panel styled to the NowPlaying Hero look from `deepdrft-tokens.css` (no hardcoded hex). A `PanelChrome` flag scopes panel chrome to the popover mount. One popover placed by the lava-lamp icon on every host — full parity across Mix, Cut, Session, and NowPlaying card.
|
||||
- **Bridge live-track fix.** The visualizer now follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`), not the fixed host `TrackId`.
|
||||
- **12.C — `Ambient` slot on `ReleaseDetailScaffold` + mount on detail pages (mode B).** New optional `Ambient` slot on `ReleaseDetailScaffold` (full-bleed layer behind content; absent slot = no regression). Cut mounts the ambient visualizer + the lava-lamp icon → popover. Session mounts the engine directly behind its hero (it doesn't compose the scaffold) + the popover. Mix swapped its inline controls bar for the lava-lamp icon → popover, keeping its own full-bleed mode-A mount.
|
||||
- **12.D — NowPlayingHero rewire (mode C).** `NowPlayingCard` replaced the 20 synthetic CSS bars with a contained `<WaveformVisualizer>` driven by the live cascaded player, pointed at the current track. Added a `Fill` container-sizing mode (CSS-only, defaults off). Placed the lava-lamp icon → popover on the card for full parity. Visualizer runs at-rest on the home page even before playback (deliberate; perf tuning deferred).
|
||||
|
||||
- **Why:** The landed Mix visualizer was structurally track-cardinal below the surface (bridge keyed on `TrackId`; renderer a pure function of a loudness datum + duration) but named `Mix*` throughout and restricted to Mix-only data. "Generalize" was a rename + per-track high-res compute extension, not a rebuild. Direction B (high-res for all media) was chosen over the cheaper 512-bucket-fallback Direction A to deliver uniform waveform quality. Controls moved from per-page inline knob bars to a single popover-hosted panel to achieve zero-cost placement on any host including the small NowPlaying card.
|
||||
|
||||
- **Shape:** `DeepDrftPublic.Client/Controls/`: `WaveformVisualizer.razor` (+ `.razor.cs`, `.razor.css`) — renamed engine, added `[Parameter] bool Fill`; `WaveformVisualizerControls.razor` — renamed, now panel content with `PanelChrome` flag; `WaveformVisualizerControlPopover.razor` — new, lava-lamp icon + `MudPopover` wrapping the panel; `WaveformZoomMapping.cs` — renamed; `ReleaseDetailScaffold.razor` (+ `.razor.cs`) — new optional `Ambient` `RenderFragment` slot; `NowPlayingCard.razor` — synthetic bars replaced, `<WaveformVisualizer Fill="true">` + `<WaveformVisualizerControlPopover>`. `DeepDrftPublic.Client/Services/`: `WaveformVisualizerControlState.cs` — renamed. `DeepDrftPublic.Client/Pages/`: `CutDetail.razor` — mounts ambient visualizer + popover; `SessionDetail.razor` — mounts engine + popover directly; `MixDetail.razor` — swaps inline controls bar for popover. `DeepDrftPublic/Interop/visualizer/WaveformVisualizer.ts` — renamed TS module. `DeepDrftContent/Processors/`: `WaveformResolution.cs` — renamed; `WaveformProfileService.cs` — `ComputeAndStoreHighResAsync` added, medium-neutral. `DeepDrftContent/Constants/VaultConstants.cs` — `TrackWaveforms = "track-waveforms"`. `DeepDrftAPI/Controllers/TrackController.cs` — `GET api/track/{trackEntryKey}/waveform/high-res` (unauthenticated) + `POST api/track/{trackId}/waveform/high-res` (ApiKey, generalized generate); `WaveformStatusDto.HasHighRes` populated. `DeepDrftAPI/Services/UnifiedTrackService.cs` — `UploadAsync` now calls `ComputeAndStoreHighResAsync` for every new track. `DeepDrftPublic/Controllers/TrackProxyController.cs` — proxy for the new high-res endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Mix Visualizer Reframe: Waves R1–R4 (Lava tuning + eight-knob controls)
|
||||
|
||||
**Landed:** 2026-06-17 on dev.
|
||||
|
||||
- **What:** A major reframe of the Mix visualizer's effects, controls, and color model, built on the landed WebGL2 Phase 10 renderer infrastructure. Four waves:
|
||||
- **Wave R1** — removed the static noise/frost texture (Daniel: "makes the screen look dirty"); implemented dynamic footer-height clip so the lava stops cleanly above the player bar; redrawn `DDIcons.LavaLamp` to the classic 1970s silhouette (wide truncated-cone base, bulbous→roundedly-pointed teardrop glass body, small cone cap — navy fluid + moss blobs, body `currentColor`).
|
||||
- **Wave R2** — CPU-side per-frame physics step (~16–32 Lagrangian wax blobs: position/velocity/temperature/radius), uploaded as uniforms each frame; `smin` SDF metaball render producing a **flat, coalescing fluid** (not blobs with radial hotspots); energy-coupled dynamics (high heat → many small turbulent bubbles; low heat → fewer large calm masses); 2D elastic collision on both blob↔waveform and blob↔blob pairs; collision strength knob sweeping from genuine soft mush to a high-elasticity upward-and-outward throw; waveform collision always on regardless of heat. Loudness profile smoothed with a **~15 ms envelope-follower at preprocessing only** (`RmsLoudnessAlgorithm.cs`); there is no decode-time smoothing (`smoothDatum` was removed). Existing vault mixes gain the smoothing only after server-side reprocessing — they do not benefit automatically. Ribbon rendered with smootherstep sinusoid reconstruction.
|
||||
- **Wave R3** — replaced HSL `mixHsl`/`vivify` color with OKLab interpolation (structural fix for the cyan excursion artifact); three combined gradient motions: (1) A/B anchor rotation among three theme colors at the gradient-rotation-speed rate; (2) per-segment sinusoidal variation keyed to mix-time so colors travel with the segment as it scrolls; (3) per-bar gradient curve shifts with scroll height (mostly A at bottom → mostly B at top). Static noise texture removed. One source of truth (`DeepDrftPalettes`), no hardcoded hexes.
|
||||
- **Wave R4** — `MixVisualizerControlState` widened from four properties to **eight**: `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, **`FluidAmount`** (replaces the former `BlobDensity`), **`FluidViscosity`** (new — cohesion / coalescence control, the second half of the bubbles split), `CollisionStrength`, `WaveformWidth`. `MixVisualizerControls` now renders **eight** `RadialKnob`s; the `Visible` parameter `@if`-gates the knob band while the container holds a reserved min-height so content below never pops when the lamp toggles. Scroll-speed knob range tuned to 60–110% band; gravity 0–75%; heat +20% at top; width default 50% / range 10–95%. The scaffold's `TopRowCenter` slot (added in the prior reframe) carries the controls in-flow between the back link and lava-lamp toggle.
|
||||
- **Why:** Daniel tested the Phase 10 effects end-to-end and rejected the visual result: lava read as "giant disconnected circles," colors drifted to cyan (HSL arc artifact), waveform and lava read as two unrelated layers. The diagnosis was that these were structural failures of the prior model (too few scripted blobs with no physics; HSL hue-arc through cyan), not tuning misses. The reframe replaced the model with CPU-physics wax blobs + OKLab gradients, fixing both root causes.
|
||||
- **Shape:** `DeepDrftContent/Processors/RmsLoudnessAlgorithm.cs` — ~15 ms envelope-follower smoothing added at preprocessing (server-side only; no decode-time smoothing). `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` — smootherstep sinusoid ribbon reconstruction; wax-blob physics loop; OKLab gradient; footer-clip; noise texture removed. `DeepDrftPublic.Client/Services/MixVisualizerControlState.cs` — widened to eight properties (`FluidAmount` + `FluidViscosity` replace `BlobDensity`; `WaveformWidth` range/default updated). `DeepDrftPublic.Client/Controls/MixVisualizerControls.razor` — eight `RadialKnob`s, `Visible` parameter gates knob band via `@if` while container holds reserved height. `DeepDrftShared.Client/Common/DDIcons.cs` — `LavaLamp` glyph redrawn. Full design, acceptance criteria, and phasing: `product-notes/phase-10-mix-visualizer-lava-reframe.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11 — Public Site Enhancements (complete — all tracks 11.A–11.H landed 2026-06-16)
|
||||
|
||||
### 11.H — release `EntryKey` identifiers (terminal public-site wave)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Front the release `long` PK with an app-minted GUID-string `EntryKey` column — the same pattern `TrackEntity.EntryKey` uses. `ReleaseEntity.EntryKey` is `required string`, minted as `Guid.NewGuid().ToString()` at the `FindOrCreateRelease` path; `ReleaseDto.EntryKey` mirrors it; `TrackConverter` round-trips it. The public addressing surface was re-typed from `long` to the `EntryKey` string handle: detail routes (`/cuts`, `/sessions`, `/mixes`, and the `/tracks/{id}` redirect), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, the public read path (`IReleaseDataService.GetByEntryKey`), and the public release API (`GET api/release/{entryKey}`, the mix waveform endpoint). The `releaseId` track-page query is resolved client-side from the EntryKey-loaded release and stays `long` (never enters a navigable URL). The internal `long` PK and all internal FKs (`TrackEntity.ReleaseId`, `SessionMetadata.ReleaseId`, `MixMetadata.ReleaseId`) are unchanged — DB-only, unused by the app. ApiKey-gated CMS endpoints stay on the int PK. EF migration `20260616210143_AddReleaseEntryKey` authored; **not yet applied** (Daniel-gated; must follow 11.G's `20260616035252_AddReleaseDescription` in apply order). The migration adds the `entry_key` column, backfills a unique GUID string per existing release row at migration time, then sets NOT NULL + unique index.
|
||||
- **Why:** The release `long` PK was leaking into navigable public URLs (`/cuts/{long}`, `/sessions/{long}`, `/mixes/{long}`), exposing sequential internal IDs and making public addresses dependent on DB identity. An app-minted opaque GUID handle (the pattern already established by `TrackEntity.EntryKey`) decouples the public addressing surface from the storage PK, enables backfilling existing rows without a dev reset, and completes the commitment-9 scope of Phase 11.
|
||||
- **Shape:** `ReleaseEntity.EntryKey` (`required string`) in `DeepDrftModels/Entities/`; `ReleaseConfiguration` adds the `entry_key` column config + unique index. `ReleaseDto.EntryKey` in `DeepDrftModels/DTOs/`. `TrackConverter` maps EntryKey on both read and write paths. `FindOrCreateRelease` (`DeepDrftData/TrackManager.cs`) mints `Guid.NewGuid().ToString()` on new-release creates. Public API route params re-typed to string EntryKey: `GET api/release/{entryKey}` + mix waveform endpoint. `IReleaseDataService.GetByEntryKey` (public read path). Detail page routes (`/cuts/{entryKey}`, `/sessions/{entryKey}`, `/mixes/{entryKey}`), `ReleaseRoutes.DetailHref`, `SharePopover.ReleaseId`, `TrackRedirect.razor`. Migration `20260616210143_AddReleaseEntryKey` authored but not applied.
|
||||
|
||||
---
|
||||
|
||||
### 11.D — Archive filters in the URL
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** `ArchiveView` filter state (`q`, `medium`, `genre`) is now URL-bound via `[SupplyParameterFromQuery]`, making every filtered archive view a shareable, bookmarkable address (`/archive?q=&medium=&genre=`). Filter handlers navigate only; the seed-and-fetch reaction moved to `OnParametersSetAsync` (history-driven, §5.3 Option A). A `_loadedFilterKey` idempotency guard composed from the three-axis filter triple makes same-route query changes (debounce/chip-nav races, back/forward history) a no-op when the filter set is unchanged. The `HasActiveFilter` prerender-persistence gate is preserved: a filtered direct-load fetches its own narrowed result; a plain `/archive` visit restores the bridged first page. `medium` is parsed leniently with `Enum.TryParse(ignoreCase: true)` + `Enum.IsDefined` so a stray token degrades to All. Folded-in cleanup: `GenresView` genre-tile click was repointed from the deleted `/tracks?genre=` route to `/archive?genre=`, closing the 11.C dead-link residual — no `/tracks?genre=` references remain in the codebase.
|
||||
- **Why:** Archive filters were held in component fields with no URL representation, so a filtered view had no shareable address and the browser's back button did not restore the previous filter state. URL-binding makes the filter model consistent with the TracksView `[SupplyParameterFromQuery]` pattern already in the codebase and is a prerequisite for 11.H (which re-types the addressing surface 11.D defines).
|
||||
- **Shape:** `ArchiveView.razor.cs` (`DeepDrftPublic.Client/Pages/`): added three `[SupplyParameterFromQuery]` properties (`QueryParam`, `MediumParam`, `GenreParam`); added `_loadedFilterKey` string field + `ComposeFilterKey()` method; moved the seed-and-fetch reaction from `OnInitializedAsync` to `OnParametersSetAsync` with the idempotency guard; filter handlers (`OnSearchInput`, `OnMediumSelected`, `OnGenreSelected`) rewritten to call `NavigateToFilter` (navigate-only). `SeedFromQuery()` private method maps query params onto the component's filter fields with lenient enum parsing. `GenresView.razor.cs` (`DeepDrftPublic.Client/Pages/`): genre-tile click repointed to `/archive?genre=` from the former `/tracks?genre=`.
|
||||
|
||||
---
|
||||
|
||||
### 11.E — release-level Share
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** `SharePopover` gained a release-keyed mode alongside the existing track-keyed mode. Two new parameters: `ReleaseId` (`long?`) and `ReleaseMedium` (`ReleaseMedium`). When `ReleaseId` is set, "Copy link" copies the absolute URL formed from `ReleaseRoutes.DetailHref(id, medium)` composed against `NavigationManager.BaseUri`; the "Embed player" affordance is hidden entirely — release pages are not single-track embeds. The existing track-keyed mode (`EntryKey`, copy link + embed) is unchanged. `IsReleaseMode` is a private derived bool (`ReleaseId is not null`) that drives the branch. `CutDetail.razor`'s header Share button now passes `ReleaseId` and `ReleaseMedium` from the loaded release — unconditional, no longer gated on a track being present. Session and Mix detail headers were not touched.
|
||||
- **Why:** Cuts had no shareable release-level URL — the Share button in `CutDetail` was wired to a track entry key. With the Cut detail page now the canonical address for an album, sharing should copy the album URL (`/cuts/{id}`), not a per-track URL. A single popover component serving both modes avoids duplicating clipboard/popover-chrome logic.
|
||||
- **Shape:** `SharePopover.razor.cs` (`DeepDrftPublic.Client/Controls/`): added `[Parameter] public long? ReleaseId { get; set; }`, `[Parameter] public ReleaseMedium ReleaseMedium { get; set; }`, `private bool IsReleaseMode => ReleaseId is not null`, and a `LinkUrl` computed property that branches on `IsReleaseMode`. `SharePopover.razor`: embed section wrapped in `@if (!IsReleaseMode)`. `CutDetail.razor`: Share button updated to `<SharePopover ReleaseId="@release.Id" ReleaseMedium="@release.Medium" />`.
|
||||
|
||||
---
|
||||
|
||||
### 11.C — retire track-cardinal stack + normalize release cards
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Deleted the entire track-cardinal stack: `TracksView.razor` + `.razor.cs` + `.css`, `TrackDetail.razor` + `.razor.cs`, `TrackCard.razor` + `.css`, `TracksGallery.razor` + `.css`, `GalleryViewMode`, and the orphaned `TracksViewModel` + `TrackDetailViewModel`. Their DI registrations were removed from `Startup.cs`. `/tracks` was cleaned from the nav index (`Pages.cs`) and the `DeepDrftHero` + `Home` CTAs were repointed from `/tracks` to `/archive`. Routes `/tracks` and `/track/{EntryKey}` are gone; the `/albums` redirect and the `/tracks/{id}` release-id redirect (`TrackRedirect.razor`) both survive. On the normalize side: `ReleaseGallery` is now the single release-card grid across all browse surfaces, generalized with an optional `HrefResolver` parameter (per-card medium routing via `ReleaseRoutes.DetailHref`) and a `SubtitleResolver` parameter (Cuts show "N tracks", others show artist). `ArchiveView` and `AlbumsView` folded their inline card markup and CSS into `ReleaseGallery` via these new parameters; Sessions and Mixes continue on the back-compat `DetailRoute` path unchanged. Known residual (not fixed): `GenresView.razor.cs` still links to the deleted `/tracks?genre=` route (left intentionally — `/genres` is out of Phase 11 scope); one orphaned `.deepdrft-track-card-link` CSS rule remains in the `DeepDrftPublic` host stylesheet.
|
||||
- **Why:** 11.B removed every inbound link to the track-cardinal stack (Archive/AlbumsView cards and the player-bar title all route through `ReleaseRoutes` now), so the stack became dead code. Deleting it removes several files and two view-models from the interactive surface and prevents stale routes from being accidentally discoverable. The release-card normalization was the companion half of the commitment: Archive and Cuts had been reimplementing card markup inline rather than using the shared `ReleaseGallery`, so a new medium or a card-design tweak required edits in three places.
|
||||
- **Shape:** Deleted from `DeepDrftPublic.Client/Pages/`: `TracksView.razor`, `TracksView.razor.cs`. Deleted from `DeepDrftPublic.Client/Controls/`: `TrackCard.razor`, `TrackCard.razor.css`, `TracksGallery.razor`, `TracksGallery.razor.css`, `GalleryViewMode`. Deleted from `DeepDrftPublic.Client/ViewModels/`: `TracksViewModel.cs`, `TrackDetailViewModel.cs`. `Startup.cs`: DI registrations for deleted view-models removed. `Pages.cs` (`DeepDrftPublic.Client/Layout/`): `/tracks` removed from `MenuPages`. `DeepDrftHero.razor` and `Home.razor`: CTAs repointed to `/archive`. `ReleaseGallery.razor` (`DeepDrftPublic.Client/Controls/`): new `[Parameter] public Func<ReleaseDto, string>? HrefResolver { get; set; }` and `[Parameter] public Func<ReleaseDto, string>? SubtitleResolver { get; set; }` parameters; `CardHref` private method branches on `HrefResolver` presence. `ArchiveView.razor` and `AlbumsView.razor` (or `.razor.cs`): inline card markup removed, delegated to `ReleaseGallery` with `HrefResolver` and (for Cuts) `SubtitleResolver`.
|
||||
|
||||
---
|
||||
|
||||
### 11.B — `ReleaseRoutes` resolver + repoint
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New shared `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the single source of truth for resolving a release to its dedicated detail route. `ReleaseRoutes.DetailHref(long id, ReleaseMedium)` returns `/cuts/{id}`, `/sessions/{id}`, or `/mixes/{id}`; a convenience overload `DetailHref(ReleaseDto)` delegates to the primary. `ArchiveView`'s former private `DetailHref` switch was removed and replaced by this shared resolver. The player-bar title (`TrackMetaLabel`), Archive cards, and `AlbumsView` Cut cards all route through the shared resolver. A thin `/tracks/{id}` redirect page (`Pages/TrackRedirect.razor`) handles bare-release-id deep links: it fetches the release to discover its medium, resolves through `ReleaseRoutes.DetailHref`, and performs a history-replacing redirect — one medium→route table, no second source. The track-cardinal stack (`TrackDetail`/`TracksView`/etc.) was deliberately not touched — that is 11.C.
|
||||
- **Why:** Multiple call sites (Archive, AlbumsView, player bar) each maintained their own medium→route mapping. A fourth medium or a route rename would require hunting all of them. Centralising into one static helper makes the medium→detail-page contract explicit in one place and removes the risk of call sites drifting.
|
||||
- **Shape:** `ReleaseRoutes.cs` (new, `DeepDrftPublic.Client/Common/`): static class, two `DetailHref` overloads. `ArchiveView.razor`: private `DetailHref` switch removed; calls delegate to `ReleaseRoutes.DetailHref`. `TrackMetaLabel.razor` and `AlbumsView.razor.cs`: updated to call `ReleaseRoutes.DetailHref`. `TrackRedirect.razor` (new, `DeepDrftPublic.Client/Pages/`, route `/tracks/{Id:long}`): fetches release via `IReleaseDataService.GetById`, resolves through `ReleaseRoutes.DetailHref`, navigates with `replace: true`; falls back to `/cuts` on unknown id.
|
||||
|
||||
---
|
||||
|
||||
### §3.4 PlayAlbum queue seam — wired (follow-up to 11.A + 11.F)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** The §3.4 integration seam between 11.A (`/cuts/{id}`) and 11.F (`IQueueService`) is now closed. `CutDetail.razor` consumes the cascaded `IQueueService`: header Play calls `Queue.PlayRelease(ViewModel.Tracks, 0)` (loads the full album as an ordered queue starting at track 0); per-row play calls `Queue.PlayRelease(ViewModel.Tracks, index)` (album continues from the chosen track). The currently-playing row still toggles play/pause via `IPlayerService.TogglePlayPause`. Null-safe fallback to `PlayerService.SelectTrackStreaming` is retained for prerender/non-interactive contexts where the queue cascade is absent. Consumption-only — no changes to `IQueueService`, `QueueService`, the player, or `AudioPlayerProvider`.
|
||||
- **Why:** 11.A shipped with a documented one-line seam in `PlayAlbum()` noting the future swap to `IQueueService.PlayRelease`. 11.F landed the queue. This follow-up closes the seam so the Cut detail page actually plays the full album as an ordered queue rather than single-track only.
|
||||
- **Shape:** `CutDetail.razor` (`DeepDrftPublic.Client/Pages/`) adds `[CascadingParameter] public IQueueService? Queue { get; set; }` and rewrites `PlayAlbum()` and `PlayTrack()` to branch on `Queue is not null` before falling back to direct `SelectTrackStreaming`.
|
||||
|
||||
---
|
||||
|
||||
### 11.A — `/cuts/{id}` album-detail page
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New public Cut album detail page at `/cuts/{id}`. Composes `ReleaseDetailScaffold` via a generalized `Header` slot (left meta: name, artist, genre, year, Play + Share) and a `BodyContent` slot (right theme-bordered cover image; `TrackNumber`-ordered track list with per-row play). `CutDetailBase` carries the multi-track prerender bridge across the prerender→WASM seam (following the `ReleaseDetailBase` pattern); `CutDetailViewModel` holds the loaded state. Header Play and per-row play wire into the existing single-slot `IPlayerService` (`SelectTrackStreaming` / toggle). A `PlayAlbum` method contains a documented one-line seam for a future swap to `IQueueService.PlayRelease` — queue integration is a deferred follow-up, not live in this wave. Reuses the existing `GetById` release endpoint and the `releaseId`-filtered track page; no new API surface. Track ordinal (`TrackNumber`) was verified already built and consumed correctly — no new schema.
|
||||
- **Why:** Cuts (Studio releases) had no single-release detail page — `/cuts` cards navigated to `/tracks?album={title}` (a track-cardinal view). This makes the album the primary navigable unit on the public site for Cut releases, completing the per-medium detail page set alongside `/sessions/{id}` and `/mixes/{id}`.
|
||||
- **Shape:** New `CutDetail.razor` + `CutDetailBase.cs` + `CutDetailViewModel.cs` in `DeepDrftPublic.Client`. Composes `ReleaseDetailScaffold` with `Header` and `BodyContent` render fragments. Track list ordered by `TrackNumber`; per-row play binds to `IPlayerService` (`SelectTrackStreaming` / toggle). `PersistentComponentState` bridge is owned by `CutDetailBase` (keyed `"cut-tracks"`).
|
||||
|
||||
---
|
||||
|
||||
### 11.F — play-queue `IQueueService`
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** A separate `IQueueService` orchestrating album (ordered multi-track) playback above the single-slot player. Holds an ordered track list, a current index, and `Next()`/`Previous()` skip navigation wired into the player-bar controls (skip-forward gated on `HasNext`, skip-back gated on `HasPrevious`). Auto-advance via a new `IPlayerService.TrackEnded` event (raised only on organic end-of-stream): `OnTrackEnded` advances the queue only when `player.CurrentTrack.Id == queue.Current.Id` — an `Id`-equality cross-advance guard that prevents a superseding direct-play call from accidentally advancing the queue. `Attach(IStreamingPlayerService)` binds the queue to the player (called once by `AudioPlayerProvider`); loading a track list into the queue is a separate concern via `PlayRelease`. No detach-on-direct-Play mechanism. Provider-owned and cascaded — not DI-registered, by design. Surface members: `Items`, `CurrentIndex`, `Current`, `HasNext`, `HasPrevious`, `QueueChanged` event; methods `Attach(IStreamingPlayerService)`, `PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)`, `Next()`, `Previous()`, `Enqueue`, `EnqueueRange`, `Clear`.
|
||||
- **Why:** The player was single-slot only. The Cut album detail page (11.A) needs "play album" — an ordered queue that advances through tracks end-to-end. Absorbs the queue half of Phase 1 §1.3 (the preload half remains deferred). Prerequisite for a future `PlayAlbum` integration in 11.A; also exposes skip controls in the player bar.
|
||||
- **Shape:** New `IQueueService` interface + `QueueService` implementation in `DeepDrftPublic.Client`. `IPlayerService` gains `TrackEnded` event. Player bar gains skip-forward and skip-back controls bound to `IQueueService.Next()`/`Previous()`, gated on `HasNext`/`HasPrevious`. `Attach(IStreamingPlayerService)` wires the queue to the player without constructor growth; `PlayRelease(IEnumerable<TrackDto>, int)` loads an ordered track list and starts playback.
|
||||
|
||||
---
|
||||
|
||||
### 11.G — release Description schema slice
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** New nullable `ReleaseEntity.Description` column (plain text, max 4000 characters) on the base release table, mirrored in `ReleaseDto.Description`. `TrackConverter` round-trip updated. Write-path plumbing threaded wherever `Genre` is: `UpdateTrackMetadataRequest` + upload form fields + `UnifiedTrackService` + `TrackManager` update path. CMS `AlbumHeaderFields` gains a multiline `MudTextField` for Description input. Detail-page rendering deliberately deferred — Description degrades cleanly (null renders nothing) so schema and render can land in either order. EF migration `20260616035252_AddReleaseDescription` authored; **not yet applied** (Daniel-gated).
|
||||
- **Why:** Commitment 8 from the Phase 11 spec. No `Description` member existed on `ReleaseEntity` or `ReleaseDto` prior to this wave. A base-release free-text field (uniform across all media) lets admins describe a release context, inspiration, or credits. Lives on the base release, not a per-medium satellite (consistent with Phase 9's open/closed spine).
|
||||
- **Shape:** `ReleaseEntity.Description` nullable string in `DeepDrftData`. EF `ReleaseConfiguration` adds max-length annotation (4000). `ReleaseDto.Description` nullable string. `TrackConverter` updated to map the field on both read and write paths. `UpdateTrackMetadataRequest` gains `Description` field. Upload form (multipart) gains `description` form field. `AlbumHeaderFields.razor` gains a multiline `MudTextField`. Migration `20260616035252_AddReleaseDescription` authored but not applied.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Mix detail Hero + MetaContent overlay (presentation only)
|
||||
|
||||
**Landed:** 2026-06-16 on dev.
|
||||
|
||||
- **What:** Extracted a shared **`ReleaseHeroOverlay`** presentational component (`DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` + `.razor.css`) that both Session detail and Mix detail now consume — one source of truth for the background-image hero with all metadata overlaid (genre/date + share top row; cover-thumb/title/artist + play bottom row). Mix detail's hero is now an overlaid ~600px square cover, replacing the stacked masthead + 220px cover + meta-divider block, freeing more canvas for the lava-lamp visualizer. The Phase 10 reframe top row (`TopRowCenter` controls + lava-lamp `TopRightAction`) is preserved unchanged. `ReleaseDetailScaffold` gained a `bool ShowHeader = true` gate (slot-consistent with `ShowMeta`/`ShowShareRow`) to suppress the duplicate masthead for Mix. The background-image surface is a plain `<div class="release-hero-img">` (no `MudPaper`).
|
||||
- **Why:** The Mix detail page carried a stacked masthead + 220px cover + meta-divider block that kept the overlay aesthetic of Sessions from applying and wasted vertical canvas the lava-lamp visualizer could use. Extracting `ReleaseHeroOverlay` delivered the DRY win (one overlay, no duplication) and brought Mix into the same design family as Sessions, while the `ShowHeader` gate gave the scaffold a clean suppression mechanism rather than an empty-fragment hack.
|
||||
- **Shape:** New `DeepDrftPublic.Client/Controls/ReleaseHeroOverlay.razor` (+ `.razor.css`) — the shared overlay, parameterized for `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` slot, `PlayContent` slot, `Class`. `SessionDetail.razor` — inline hero-overlay replaced by `<ReleaseHeroOverlay ... />`; behavior-preserving lift. `SessionDetail.razor.css` — overlay cascade moved to the shared component; page-specific rules remain. `MixDetail.razor` — old `.mix-detail-cover` `Hero` slot replaced with `<ReleaseHeroOverlay Class="mix-hero" ... />` in the scaffold's `Hero` slot; `MetaContent` dropped (metadata now in the overlay); share row moved into the overlay's `ShareContent` slot; scaffold used with `ShowHeader="false"`. `MixDetail.razor.css` — `mix-hero` square/medium sizing override added; `.mix-detail-cover` removed. `ReleaseDetailScaffold.razor` — `bool ShowHeader = true` gate added around the default header region.
|
||||
|
||||
Full design, DRY trade-offs, acceptance criteria, and the open questions resolved during implementation: `product-notes/mix-detail-hero-overlay.md`.
|
||||
|
||||
---
|
||||
|
||||
## CMS Grid Refinements
|
||||
|
||||
### `CmsAlbumBrowser` special-action column promotion
|
||||
|
||||
**Landed:** 2026-06-15 on branch `cms-special-action-columns`.
|
||||
|
||||
Follow-on refinement of 8.C: the `RenderFragment<ReleaseDto>? RowActions` slot that 8.C introduced into `CmsAlbumBrowser` was replaced by a dedicated, header-labelled column model so that medium-specific actions (Mix waveform, Session hero) each appear in their own named grid column rather than being merged into the shared Actions cell.
|
||||
|
||||
- **What:** `CmsAlbumBrowser.razor` removed the `[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }` slot. In its place: `[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; }` (defaulting to `Array.Empty<SpecialActionColumn>()`). `SpecialActionColumn` is a new `sealed record` (`string Header`, `RenderFragment<ReleaseDto> Cell`) in `DeepDrftManager/Components/Pages/Tracks/SpecialActionColumn.cs`. The grid renders one dedicated `<MudTh>` per declared column (between the Tracks header and the Actions header) and one `<MudTd>` per row per column. Child-row colspan moves from the hardcoded `9` to a computed `ColumnCount` property (`private const int BaseColumnCount = 9; private int ColumnCount => BaseColumnCount + SpecialColumns.Count`).
|
||||
- **Why:** Merging a per-medium affordance into the generic Actions cell forced the admin to parse mixed content in a single column. Promoting each to its own labelled column gives the grid a discoverable header for every action kind and makes it obvious at a glance which column is the Waveform column vs. the Actions column.
|
||||
- **Shape:** `CmsMixBrowser` declares one column: `new SpecialActionColumn("Waveform", WaveformCell)` — the Mix waveform generate/regenerate button with status icon. `CmsSessionBrowser` declares one column: `new SpecialActionColumn("Hero", HeroCell)` — the Session hero thumbnail preview plus set/replace upload button. `CmsCutBrowser` and the ALL-releases grid (`CmsAllReleasesGrid`) declare none; their column count and rendering are unchanged. Both callers allocate `_specialColumns` once in `OnInitialized` (field initializers cannot reference instance members; initialization is deferred to the first lifecycle hook). No change to `CmsMediumBrowserBase.cs`, `TrackList.razor`, or any other file.
|
||||
|
||||
**Completion note:** `CmsAlbumBrowser.razor` — `RowActions` parameter removed; `SpecialColumns` parameter added; `BaseColumnCount = 9` constant + `ColumnCount` computed property added; header and row loops updated to `foreach (var column in SpecialColumns)`. `SpecialActionColumn.cs` (new file, `DeepDrftManager/Components/Pages/Tracks/`): `public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell)`. `CmsMixBrowser.razor` — `RowActions` fragment replaced with `_specialColumns` field (allocated in `OnInitialized`) passed via `SpecialColumns="_specialColumns"`. `CmsSessionBrowser.razor` — same pattern. `CmsCutBrowser.razor` and `CmsAllReleasesGrid.razor` — no change (declare no special columns). No automated tests (no bUnit harness in DeepDrftTests; consistent with all prior Wave 8 / post-Phase-9 CMS tracks).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Release Medium Types
|
||||
|
||||
### 9.7 Wave 7 — Domain Invariant Hardening: per-medium track cardinality
|
||||
@@ -34,11 +398,11 @@ The single-track-per-release rule for Session/Mix is enforced only in the CMS fo
|
||||
|
||||
---
|
||||
|
||||
### 9.8 Wave 8 — Remediation (Phase-9-completion gate met: 8.A–8.J + 8.L landed; 8.K post-Phase-9, 8.M follow-on pending)
|
||||
### 9.8 Wave 8 — Remediation (fully landed: 8.A–8.J + 8.L, 8.M, 8.K)
|
||||
|
||||
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L).
|
||||
**Landed:** 2026-06-13 on dev (eleven tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L); 8.M on 2026-06-14; 8.K on 2026-06-14.
|
||||
|
||||
Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) is now fully met; 8.K (Mix Visualizer) is pulled out of Phase-9-completion scope (post-Phase-9 wave, design-complete); 8.M (legacy-form retirement) is a code-surface-reduction follow-on that trails.
|
||||
Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list. Wave 8 is remediation — the gap between what the specs *built* and what hands-on use *wants*. Full design, acceptance criteria, and dependencies: `product-notes/phase-9-wave-8-remediation.md`. The wave spans CMS, public site, and label polish. The Phase-9-completion gate (8.A–8.J + 8.L) was met on 2026-06-13; 8.M (legacy-form retirement follow-on) landed 2026-06-14; 8.K (Mix Visualizer redesign, post-Phase-9 wave, designed-complete before Phase 9 closed) landed 2026-06-14. Wave 8 is fully complete.
|
||||
|
||||
**8.A — Release Archive as medium tabs, not cards**
|
||||
|
||||
@@ -156,6 +520,26 @@ Daniel tested the landed Phase 9 surface (Waves 1–7) and produced a punch-list
|
||||
|
||||
---
|
||||
|
||||
**8.M — Retire the legacy single-track CMS forms**
|
||||
|
||||
- **What:** Retire `TrackNew` (`/tracks/new`) and `TrackEdit` (`/tracks/{Id:long}`) as standalone authoring forms in `DeepDrftManager`. Their responsibility is absorbed by `BatchUpload` / `BatchEdit`'s single-track branch.
|
||||
- **Why:** The legacy forms were a duplicate code surface. Folding their function into the batch forms reduces form surface and removes the addressing-model gap that existed between `TrackEdit` (addressed by track id) and `BatchEdit` (addressed by release title). Daniel's decision: "consolidate the forms and reduce the code surface" (2026-06-13).
|
||||
- **Shape:** **Option 2 (Daniel's decision):** `BatchEdit` gained a track-addressed route `/tracks/{TrackId:long}/edit` that resolves the track to its parent release via `GetByIdAsync`, loads the release through the existing release-load path, and pre-selects the addressed track's row (`ResolveInitialSelection` matches by `Id`, falls back to row 0). The existing release-title route (`/tracks/album/{AlbumName}/edit`) is untouched. The two legacy components were reduced to thin redirect shims (not hard-deleted, to guard bookmarks): `/tracks/new` → `/tracks/upload`; `/tracks/{Id}` → `/tracks/{Id}/edit`. `CmsTrackGrid`'s per-row Edit now targets `/tracks/{id}/edit`. Files changed (6): `BatchEdit.razor`, `BatchUpload.razor`, `CmsTrackGrid.razor`, `SessionFields.razor`, `TrackEdit.razor`, `TrackNew.razor`. No new component, no public-site change, no constructor growth, no `IServiceProvider`.
|
||||
|
||||
**Completion note:** `BatchEdit.razor` gained a second `@page` route `/tracks/{TrackId:long}/edit`; `OnInitializedAsync` uses `GetByIdAsync` when `TrackId` is set, resolves the parent release, loads through the existing release-load path, and calls `ResolveInitialSelection` which matches by `Id` (falls back to row 0) to pre-select the addressed track's row. The existing `/tracks/album/{AlbumName}/edit` route and its load path are untouched. `TrackEdit.razor` and `TrackNew.razor` were each reduced to thin redirect shims — `TrackNew` redirects `/tracks/new` → `/tracks/upload`; `TrackEdit` redirects `/tracks/{Id}` → `/tracks/{Id}/edit` — preserving inbound bookmarks without keeping dead form logic. `CmsTrackGrid.razor` per-row Edit link updated from `/tracks/{id}` to `/tracks/{id}/edit`. `SessionFields.razor` and `BatchUpload.razor` received minor coordinating edits. Build clean. No automated tests (DeepDrftTests has no bUnit harness / no DeepDrftManager reference — consistent with prior Wave 8 tracks). All acceptance criteria met; legacy `TrackNew`/`TrackEdit` authoring forms retired; track-addressed edit route live on `BatchEdit`.
|
||||
|
||||
---
|
||||
|
||||
**8.K — Mix Visualizer redesign (post-Phase-9 wave)**
|
||||
|
||||
- **What:** Replace the static SVG waveform silhouette on the Mix detail page with a windowed, playback-coupled, bottom-to-top scrolling Canvas 2D animation; simultaneously switch Mix loudness datum capture from a fixed 2048-bucket count to a duration-derived constant-time-resolution scheme. Strictly read-only (no seek seam); theme-aware glassy gradient aesthetic (lava-lamp idiom, MudBlazor palette, live dark-mode responsive).
|
||||
- **Why:** The static SVG silhouette did not communicate playback progress or the shape of the material at any useful zoom level. Long mixes were under-sampled at fixed 2048 buckets — the visualizer design called for ~333 samples/sec so max-zoom detail is legible. The redesign gives Mix detail pages their signature dynamic visual and makes the waveform datum meaningfully dense.
|
||||
- **Shape:** Two waves. Wave 1 (datum §F): bucket count becomes `ceil(durationSeconds × 333)`, clamped `[2048, 2_000_000]`; pure helper `MixWaveformResolution.cs` (`BucketCountForDuration`, named constants `SamplesPerSecond`/`MinBucketCount`/`MaxBucketCount`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives the count from `audio.Duration`; fixed `MixWaveformBucketCount = 2048` constant removed. Single high-density datum (not tiered/mipmap — Daniel's decision). Backward-compatible: existing 2048-bucket mixes still render coarsely; re-running the Generate trigger re-captures at new density. Wave 2 (renderer §A/B/C/D/E): `MixWaveformVisualizer` rewritten from static SVG to Canvas 2D scrolling animation driven by a `requestAnimationFrame` loop in new TS interop module `MixVisualizer.ts` (`DeepDrftPublic/Interop/visualizer/`). Guitar-Hero zoom coupling anchored at 0.333 s (1 quarter note @ 180 BPM max-zoom), range 0.333 s → 30 s, default-open 10 s. rAF loop gated on is-playing (idle on pause; one-shot redraws on zoom/theme/datum/resize while idle). Sample↔time mapping uses the DTO's `BucketCount` and the mix duration (sourced from the cascaded player, gated to the mix's `TrackId`) — no fixed-2048 assumption. New `MixZoomMapping.cs` (pure log-scaled zoom↔seconds) and `MixVisualizerZoomState.cs` (scoped, session-persistent, resets on fresh load, registered in `Startup.cs`); `MixDetail.razor` passes `TrackId`. Inert `OnSeek` + two-way `PlaybackPosition` seam dropped; `PlaybackPosition` is one-way input; `ReleaseId` self-fetches the datum. No `@rendermode` override, no constructor growth, no `IServiceProvider`; component CSS scoped.
|
||||
|
||||
**Completion note:** Wave 1 landed: `DeepDrftContent/Processors/MixWaveformResolution.cs` (new, pure helper with `BucketCountForDuration`, `SamplesPerSecond = 333`, `MinBucketCount = 2048`, `MaxBucketCount = 2_000_000`); `UnifiedReleaseService.TriggerMixWaveformAsync` derives bucket count from `audio.Duration` via the new helper; fixed `MixWaveformBucketCount = 2048` constant removed. `WaveformProfileDto.BucketCount` now varies per-mix. 8 unit tests in `MixWaveformResolutionTests.cs`. Wave 2 landed: `MixWaveformVisualizer` rewritten as a Canvas 2D scrolling component; `DeepDrftPublic/Interop/visualizer/MixVisualizer.ts` (new TS module) owns canvas, datum decode, rAF loop, scroll/zoom/compositing math, and dark-mode responsive theming. `MixZoomMapping.cs` and `MixVisualizerZoomState.cs` (new); zoom state registered as scoped in `Startup.cs`; `MixDetail.razor` passes `TrackId`. Two-way `PlaybackPosition` binding dropped; one-way input only. No automated tests for Wave 2 (DeepDrftTests references DeepDrftContent/DeepDrftData, not DeepDrftPublic.Client — consistent with prior public-site UI waves). Design spec: `product-notes/phase-9-mix-visualizer-redesign.md`. All acceptance criteria met; Wave 8 track 8.K completes the Mix Visualizer redesign and closes Wave 8 in full.
|
||||
|
||||
---
|
||||
|
||||
### 9.6 Wave 6 — Gap Closure
|
||||
|
||||
**Landed:** 2026-06-13 on dev.
|
||||
|
||||
+91
-14
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles, and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (mix waveform compute, session hero-image upload). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary), releases (SQL metadata with media-specific satellites), and images (FileDatabase binary); AuthBlocks API host (JWT auth, role/admin seed). Track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing with filters, metadata operations, waveform profiles (512-bucket player-bar seeker + per-track high-res visualizer datum), and release associations. Release endpoints provide paged listing with medium filter, single-release read, and media-specific operations (session hero-image upload; mix waveform is a caller-less legacy delegate — the track-cardinal `GET api/track/{entryKey}/waveform/high-res` is the live fetch path). Image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for authenticated endpoints, JWT + AuthBlocks for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
|
||||
## What lives here now (only)
|
||||
|
||||
@@ -77,6 +77,23 @@ Admin backfill: computes and stores a waveform profile for an existing track fro
|
||||
- Fetches audio from vault, decodes it, computes a loudness profile, and stores the profile in the `waveform-profiles` vault.
|
||||
- Returns 200 on success. Returns 404 if no audio is stored under that key. Returns 500 if WAV decoding or vault write fails.
|
||||
|
||||
### GET api/track/{trackId}/waveform/high-res (unauthenticated)
|
||||
|
||||
Track-cardinal high-res datum fetch. Returns the per-track duration-derived high-res waveform datum (~333 samples/sec) from the `track-waveforms` vault. This is the live read path for the `WaveformVisualizer` bridge — the release-level mix waveform endpoint is a caller-less legacy delegate.
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
|
||||
- Returns 200 on success. Returns 404 if no high-res datum is stored (graceful — not-yet-backfilled tracks fall back to no visualizer data). Returns 500 on vault error.
|
||||
|
||||
### POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
||||
|
||||
Server-side trigger: compute and store the per-track high-res datum for any track from its vault audio, keyed by `EntryKey` in the `track-waveforms` vault. Drives the CMS per-row "Generate high-res" action and the CMS batch backfill action. Generalised off Mix-only in Phase 12.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `trackId`** (string): the entry id (TrackEntity.EntryKey).
|
||||
- Calls `WaveformProfileService.ComputeAndStoreHighResAsync` via `UnifiedTrackService`.
|
||||
- Returns 200 on success. Returns 404 if no audio stored under that key. Returns 500 on compute/storage failure.
|
||||
|
||||
### GET api/track/meta/by-key/{entryKey} (unauthenticated)
|
||||
|
||||
Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable through the public proxy.
|
||||
@@ -87,12 +104,22 @@ Single track metadata by vault entry key (EntryKey). Unauthenticated, reachable
|
||||
|
||||
### GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
|
||||
Admin backfill view: returns every track with a flag indicating whether a waveform profile is stored. Used by the CMS PreProcessing panel to flag tracks needing waveform computation.
|
||||
Admin backfill view: returns every track with flags indicating whether each waveform type is stored. Used by the CMS track list to flag tracks needing waveform computation.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, and `HasProfile` (bool).
|
||||
- **Response**: `List<WaveformStatusDto>` with `TrackId`, `EntryKey`, `TrackName`, `HasProfile` (bool — 512-bucket player-bar seeker profile in `waveform-profiles` vault), and `HasHighRes` (bool — duration-derived high-res visualizer datum in `track-waveforms` vault).
|
||||
- Returns 200 on success. Returns 500 on query error.
|
||||
|
||||
### POST api/track/duration/backfill ([ApiKeyAuthorize])
|
||||
|
||||
Admin backfill: for every track whose `DurationSeconds` SQL column is still null, reads the `AudioBinary.Duration` from the vault and writes it to SQL. Idempotent — a re-run only touches still-null rows; rows that already have a value are skipped.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- No request body.
|
||||
- Calls `UnifiedTrackService.BackfillDurationsAsync`. Lives on `TrackController` in the literal-route block (before `{trackId}` routes, so the segment is never treated as a trackId).
|
||||
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
|
||||
- Returns 200 on success. Returns 500 if the backfill operation fails.
|
||||
|
||||
### 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).
|
||||
@@ -130,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.
|
||||
- `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.
|
||||
- `[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`).
|
||||
- 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.
|
||||
|
||||
@@ -143,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.
|
||||
- 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)
|
||||
|
||||
Paged metadata list from SQL with optional filtering. Public browser data, same auth posture as `GET api/track/{id}`.
|
||||
@@ -150,7 +188,7 @@ Paged metadata list from SQL with optional filtering. Public browser data, same
|
||||
- **Query parameters**:
|
||||
- `page` (int, optional, default 1): 1-based page number.
|
||||
- `pageSize` (int, optional, default 20): tracks per page.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`. Defaults to `Id`.
|
||||
- `sortColumn` (string, optional): sort field. Supported: `"TrackName"`, `"Artist"`, `"Album"`, `"Genre"`, `"ReleaseDate"`, `"TrackNumber"`. Defaults to `Id`.
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- `q` (string, optional): search text filter (matches track name / artist).
|
||||
- `album` (string, optional): album title filter.
|
||||
@@ -220,25 +258,25 @@ Paged release list, optionally filtered to one medium. Public browse data, same
|
||||
- `sortDescending` (bool, optional, default false): sort direction.
|
||||
- Returns 200 with `PagedResult<ReleaseDto>` on success. Returns 400 if `medium` is unrecognized. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{id:long} (unauthenticated)
|
||||
### GET api/release/{entryKey} (unauthenticated)
|
||||
|
||||
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`.
|
||||
Single release with both metadata navs (nulls for non-matching media). Public, same auth posture as `GET api/release`. Addresses releases by their opaque public `EntryKey` (GUID string), never the int PK (Phase 11 §3e).
|
||||
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- **Response**: `ReleaseDto` with `Id`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey` (the public handle).
|
||||
- **Response**: `ReleaseDto` with `Id`, `EntryKey`, `Title`, `Artist`, `Genre`, `ReleaseDate`, `Medium`, `ImagePath`, and media-specific metadata satellites (`MixMetadata` for Cut/Mix, `SessionMetadata` for Session; others null).
|
||||
- Returns 200 on success. Returns 404 if not found. Returns 500 on query error.
|
||||
|
||||
### GET api/release/{id:long}/mix/waveform (unauthenticated)
|
||||
### GET api/release/{entryKey}/mix/waveform (unauthenticated — caller-less legacy delegate)
|
||||
|
||||
Serves the high-res waveform datum for a Mix release as base64-encoded bytes. Mirrors `GET api/track/{id}/waveform` but reads from the `mix-waveforms` vault.
|
||||
Legacy endpoint: formerly served the high-res waveform datum for a Mix release from the `mix-waveforms` vault. **No longer called by the client** — the live fetch path is now the track-cardinal `GET api/track/{trackId}/waveform/high-res` (Phase 12). The endpoint is retained in the API but has no active callers. `UnifiedReleaseService.TriggerMixWaveformAsync` now delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` (the same shared seam used by the upload path and the generalized CMS generate action).
|
||||
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
- **Route parameter `entryKey`** (string): the release's `EntryKey`.
|
||||
- **Response**: `WaveformProfileDto` with `BucketCount` and `Data` (base64).
|
||||
- Returns 200 on success. Returns 404 if the release is not a Mix, carries no waveform key, or no datum is stored. Returns 500 on query/vault error.
|
||||
|
||||
### POST api/release/{id:long}/mix/waveform ([ApiKeyAuthorize])
|
||||
|
||||
Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform, store it in the `mix-waveforms` vault, and link it via `MixMetadata.WaveformEntryKey`. No request body.
|
||||
Server-side trigger: fetch the Mix's track audio from the vault, compute the duration-derived high-res datum, store it in the `track-waveforms` vault under the track's `EntryKey`, and link it via `MixMetadata.WaveformEntryKey`. Delegates to `WaveformProfileService.ComputeAndStoreHighResAsync` — the same shared seam used by the upload path and the generalized CMS generate action. No request body.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL release ID.
|
||||
@@ -255,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`.
|
||||
- Returns 200 on success. Returns 400 for missing file or unsupported MIME type. Returns 404 if release not found. Returns 500 on processing or vault failure.
|
||||
|
||||
## The stats endpoints
|
||||
|
||||
### GET api/stats/home (unauthenticated)
|
||||
|
||||
Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A single read returns everything the three cards need so the client makes one round-trip. Public, same auth posture as `GET api/track/page`.
|
||||
|
||||
- **Response**: `HomeStatsDto` with:
|
||||
- `CutTrackCount` (int): total non-deleted tracks on Cut-medium releases.
|
||||
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (zero-suppressed server-side).
|
||||
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
|
||||
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0).
|
||||
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's bucket columns (`PartialCount + SampledCount + CompleteCount`), all-time (Phase 16). Zero until the play-telemetry migration is applied.
|
||||
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` across all play events, all-time (Phase 16). Zero until the migration is applied.
|
||||
- `StatsController` injects **both** `ITrackService` (track-domain aggregation — Cuts/Mixes cards) and `IEventService` (event-domain aggregation — Plays card). Neither domain reaches into the other's tables; the controller is the thin composition seam. Track-domain aggregation comes from `TrackRepository.GetHomeStatsAsync` via `ITrackService.GetHomeStats`; play/listener figures come from `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16 wave 16.5). Play/listener reads are **best-effort**: a telemetry failure or not-yet-applied migration leaves those fields at 0 rather than failing the whole endpoint with 500.
|
||||
- Returns 200 on success. Returns 500 if the track-domain aggregation fails.
|
||||
|
||||
## The event endpoints (Phase 16 anonymous telemetry)
|
||||
|
||||
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
|
||||
|
||||
### POST api/event/play (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous play event. Client sends the track `EntryKey`, a completion bucket, and an optional `anonId` (wave 16.3); server-side release resolution joins track→release at write time (D4). The `anonId` is length-clamped server-side: whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars returns `400` rather than being truncated (truncation would collide distinct listeners).
|
||||
|
||||
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete", "anonId": "..." }` (`anonId` optional — omitted when null).
|
||||
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
|
||||
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
|
||||
|
||||
### POST api/event/share (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous share event (a clipboard write from `SharePopover`).
|
||||
|
||||
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed", "anonId": "..." }` (`anonId` optional — omitted when null; same length-clamp as the play endpoint).
|
||||
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values; `anonId` ≤ 64 chars (reject-not-truncate).
|
||||
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
|
||||
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
|
||||
@@ -289,7 +365,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
|
||||
3. Register `FileDatabase` as singleton.
|
||||
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
|
||||
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
|
||||
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -66,18 +66,26 @@ public class ReleaseController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/release/{id}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64. Mirrors GET api/track/{id}/waveform
|
||||
// but reads from the mix-waveforms vault. 404 when the release is not a Mix, carries no waveform key,
|
||||
// or no datum is stored. Declared before the shorter "{id:long}" route for clarity.
|
||||
[HttpGet("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(long id, CancellationToken ct = default)
|
||||
// GET api/release/{entryKey}/mix/waveform (unauthenticated)
|
||||
// Serves the high-res waveform datum for a Mix release as base64, reading the Mix's track datum from
|
||||
// the track-waveforms vault. 404 when the release is not a Mix, carries no waveform key, or no datum
|
||||
// is stored. Public read — addresses by the opaque EntryKey, not the int PK (§3e). The {entryKey}
|
||||
// string segment cannot collide with the ApiKey-gated POST {id:long}/mix/waveform (different verb +
|
||||
// constraint). Declared before the shorter "{entryKey}" route for clarity.
|
||||
//
|
||||
// LEGACY (phase-12 §5b): the visualizer no longer fetches through this release-addressed route — it
|
||||
// resolves the current track's datum via the track-cardinal GET api/track/{trackEntryKey}/waveform/
|
||||
// high-res. This endpoint is retained as a thin transitional delegate (it serves the identical datum,
|
||||
// since a Mix is single-track) and has no client caller today; remove it once nothing depends on the
|
||||
// release-addressed shape.
|
||||
[HttpGet("{entryKey}/mix/waveform")]
|
||||
public async Task<ActionResult> GetMixWaveform(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var lookup = await _releaseService.GetByIdAsync(id, ct);
|
||||
var lookup = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!lookup.Success)
|
||||
{
|
||||
var error = lookup.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMixWaveform lookup failed for {ReleaseId}: {Error}", id, error);
|
||||
_logger.LogError("GetMixWaveform lookup failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
@@ -85,14 +93,14 @@ public class ReleaseController : ControllerBase
|
||||
var waveformEntryKey = release?.MixMetadata?.WaveformEntryKey;
|
||||
if (release is null || release.Medium != ReleaseMedium.Mix || string.IsNullOrEmpty(waveformEntryKey))
|
||||
{
|
||||
_logger.LogInformation("No mix waveform datum for release: {ReleaseId}", id);
|
||||
_logger.LogInformation("No mix waveform datum for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.MixWaveforms);
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(waveformEntryKey, VaultConstants.TrackWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {ReleaseId}", id);
|
||||
_logger.LogInformation("Mix waveform key set but no datum stored for release: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
@@ -104,9 +112,10 @@ public class ReleaseController : ControllerBase
|
||||
}
|
||||
|
||||
// POST api/release/{id}/mix/waveform ([ApiKeyAuthorize], no body)
|
||||
// Server-side trigger: fetch the Mix's track audio from the vault, compute a 2048-bucket waveform,
|
||||
// store it in the mix-waveforms vault, and set MixMetadata.WaveformEntryKey. 404 when the release is
|
||||
// missing or has no stored audio; 500 on compute/storage failure. Declared before "{id:long}".
|
||||
// Server-side trigger: fetch the Mix's track audio from the vault, compute a duration-derived high-res
|
||||
// waveform via ComputeAndStoreHighResAsync, store it in the track-waveforms vault, and set
|
||||
// MixMetadata.WaveformEntryKey. 404 when the release is missing or has no stored audio; 500 on
|
||||
// compute/storage failure. Declared before "{id:long}".
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/mix/waveform")]
|
||||
public async Task<ActionResult> GenerateMixWaveform(long id, CancellationToken ct = default)
|
||||
@@ -159,17 +168,18 @@ public class ReleaseController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// GET api/release/{id} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Declared after the longer
|
||||
// "{id:long}/mix/waveform" routes so the segmented routes resolve first.
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult> GetReleaseById(long id, CancellationToken ct = default)
|
||||
// GET api/release/{entryKey} (unauthenticated)
|
||||
// Single release with both metadata navs (nulls for non-matching media). Public read — addresses by
|
||||
// the opaque EntryKey, not the int PK (§3e). Declared after the longer "{entryKey}/mix/waveform"
|
||||
// route so the segmented route resolves first.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetReleaseByEntryKey(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var result = await _releaseService.GetByIdAsync(id, ct);
|
||||
var result = await _releaseService.GetByEntryKeyAsync(entryKey, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetReleaseById failed for {ReleaseId}: {Error}", id, error);
|
||||
_logger.LogError("GetReleaseByEntryKey failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load release");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,9 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
|
||||
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
// Admin backfill view: returns every track with a flag for whether a waveform profile is
|
||||
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
|
||||
// Admin backfill view: returns every track with flags for whether each waveform datum is stored —
|
||||
// the 512-bucket player-bar profile (WaveformProfiles vault) and the per-track high-res visualizer
|
||||
// datum (TrackWaveforms vault, phase-12 §5). The catalogue is small enough that the CMS panel reads
|
||||
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||
// segment is never treated as a trackId.
|
||||
[ApiKeyAuthorize]
|
||||
@@ -158,37 +159,61 @@ public class TrackController : ControllerBase
|
||||
foreach (var track in tracks.Value)
|
||||
{
|
||||
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||
var highRes = await _waveformProfileService.GetProfileAsync(track.EntryKey, VaultConstants.TrackWaveforms);
|
||||
status.Add(new WaveformStatusDto
|
||||
{
|
||||
TrackId = track.Id,
|
||||
EntryKey = track.EntryKey,
|
||||
TrackName = track.TrackName,
|
||||
HasProfile = profile is not null,
|
||||
HasHighRes = highRes is not null,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// POST api/track/duration/backfill ([ApiKeyAuthorize], no body)
|
||||
// One-time admin backfill: for every track whose SQL duration is still null, read the duration from
|
||||
// the vault audio and write it to SQL. Mirrors the waveform backfill posture. Idempotent — a re-run
|
||||
// only touches still-missing rows. Returns { updated, skipped }. Declared in the literal-route block
|
||||
// (before "{trackId}") so the segment is never treated as a trackId.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("duration/backfill")]
|
||||
public async Task<ActionResult> BackfillDurations(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _unifiedService.BackfillDurationsAsync(cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillDurations failed: {Error}", error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
return Ok(new { updated = result.Value.Updated, skipped = result.Value.Skipped });
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
// UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
|
||||
// hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
|
||||
// outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
|
||||
// limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below
|
||||
// int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
[RequestSizeLimit(2_000_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? description,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
@@ -283,6 +308,7 @@ public class TrackController : ControllerBase
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
string.IsNullOrWhiteSpace(description) ? null : description,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
@@ -412,6 +438,7 @@ public class TrackController : ControllerBase
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.Description = request.Description;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
@@ -473,6 +500,86 @@ public class TrackController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// POST api/track/{id}/replace-audio ([ApiKeyAuthorize])
|
||||
// Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
|
||||
// release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
|
||||
// vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
|
||||
// streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
|
||||
// literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
|
||||
// resolves to the parameterized "{trackId}" GET.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/replace-audio")]
|
||||
[RequestSizeLimit(2_000_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
||||
public async Task<ActionResult> ReplaceAudio(
|
||||
long id,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("ReplaceAudio called: id={Id}, size={Size}", id, audioFile?.Length);
|
||||
|
||||
if (audioFile is null || audioFile.Length == 0)
|
||||
{
|
||||
return BadRequest("Audio file is required");
|
||||
}
|
||||
|
||||
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
||||
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
||||
{
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
// 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])
|
||||
// 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
|
||||
@@ -571,6 +678,31 @@ public class TrackController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/waveform/high-res (unauthenticated)
|
||||
// Track-cardinal high-res datum fetch (phase-12 §5b): returns the per-track high-res waveform datum
|
||||
// from the track-waveforms vault, base64-encoded, keyed by EntryKey. This is what the lava visualizer
|
||||
// fetches for whatever track is currently playing/selected — the release is only addressing context.
|
||||
// Distinct from GET {trackId}/waveform (the 512-bucket player-bar profile in the default vault): the
|
||||
// "high-res" suffix selects the duration-derived TrackWaveforms datum. 404 when no high-res datum is
|
||||
// stored (a track not yet backfilled — the visualizer blanks gracefully). Declared before the
|
||||
// parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
||||
[HttpGet("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GetHighResWaveform(string trackId)
|
||||
{
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(trackId, VaultConstants.TrackWaveforms);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("No high-res waveform datum for track: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
||||
// Admin backfill: compute and store a waveform profile for an existing track from its vault
|
||||
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
|
||||
@@ -597,6 +729,35 @@ public class TrackController : ControllerBase
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform/high-res ([ApiKeyAuthorize])
|
||||
// Track-cardinal generalization of the Mix-only waveform trigger (phase-12 §5): compute and store
|
||||
// the per-track high-res datum for ANY track from its vault audio, keyed by EntryKey in the
|
||||
// track-waveforms vault. Drives the CMS per-row "Generate high-res" action and the batch backfill.
|
||||
// Re-runnable: a second call recomputes and overwrites. trackId is the EntryKey. 404 when no audio
|
||||
// is stored under that key; 500 when the WAV cannot be decoded or the vault write fails. Declared
|
||||
// before the parameterized PUT "{trackId}" route so the literal "waveform/high-res" segment wins.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/waveform/high-res")]
|
||||
public async Task<ActionResult> GenerateHighResWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateHighResWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, trackId, audio.Duration);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("GenerateHighResWaveform: computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate high-res waveform datum.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
|
||||
@@ -16,6 +16,7 @@ public record UpdateTrackMetadataRequest(
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
string? Description,
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
|
||||
@@ -8,8 +8,10 @@ using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
// Required credential files — must exist before the app will start.
|
||||
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
|
||||
@@ -64,6 +66,14 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
|
||||
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
|
||||
// release-resolution + counter-bump transaction.
|
||||
builder.Services
|
||||
.AddScoped<EventRepository>()
|
||||
.AddScoped<EventManager>()
|
||||
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
|
||||
|
||||
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
|
||||
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
|
||||
builder.Services
|
||||
@@ -118,6 +128,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
|
||||
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
|
||||
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
|
||||
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
|
||||
// budget is generous for a real listening session and only bites on scripted spam.
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("events", httpContext =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
|
||||
@@ -136,6 +165,11 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseCors("ContentApiPolicy");
|
||||
|
||||
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
|
||||
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
|
||||
// unaffected (no global limiter is set).
|
||||
app.UseRateLimiter();
|
||||
|
||||
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
|
||||
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
|
||||
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
|
||||
|
||||
@@ -17,10 +17,6 @@ namespace DeepDrftAPI.Services;
|
||||
/// </summary>
|
||||
public class UnifiedReleaseService
|
||||
{
|
||||
// High-res bucket count for Mix waveforms — 4x the player-bar default (512), feeding the
|
||||
// public-site MixWaveformVisualizer.
|
||||
private const int MixWaveformBucketCount = 2048;
|
||||
|
||||
/// <summary>Error message returned when the Mix release has no linked track.</summary>
|
||||
public const string MixHasNoTrackMessage = "Mix release has no track.";
|
||||
|
||||
@@ -104,9 +100,14 @@ public class UnifiedReleaseService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res (2048-bucket) waveform datum,
|
||||
/// store it in the MixWaveforms vault under the track's EntryKey, then point the release's Mix
|
||||
/// satellite at that same key. The datum key equals the track's EntryKey — the Mix is single-track.
|
||||
/// Fetch the Mix's track audio from the vault, compute a high-res waveform datum at a constant time
|
||||
/// resolution (≈333 samples/sec derived from the track's duration; see
|
||||
/// <see cref="WaveformResolution"/>), store it in the TrackWaveforms vault under the track's
|
||||
/// EntryKey, then point the release's Mix satellite at that same key. The datum key equals the
|
||||
/// track's EntryKey — the Mix is single-track. Under the per-track model (phase-12 §5) this is the
|
||||
/// same datum every track now carries. The visualizer fetches it via the track-cardinal
|
||||
/// <c>GET api/track/{trackEntryKey}/waveform/high-res</c> (12.B2); the Mix satellite link and the
|
||||
/// legacy release-addressed read path are retained transitionally and no longer feed the visualizer.
|
||||
/// </summary>
|
||||
public async Task<Result> TriggerMixWaveformAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
@@ -149,8 +150,11 @@ public class UnifiedReleaseService
|
||||
return Result.CreateFailResult(MixTrackNoAudioMessage);
|
||||
}
|
||||
|
||||
var computed = await _waveformProfileService.ComputeAndStoreAsync(
|
||||
audio.Buffer, entryKey, MixWaveformBucketCount, VaultConstants.MixWaveforms);
|
||||
// Duration-derived, constant-time-resolution capture (≈333 samples/sec) so long mixes are not
|
||||
// under-sampled by a fixed bucket count — see WaveformResolution / spec §F. Same per-track
|
||||
// high-res datum every track now carries (phase-12 §5).
|
||||
var computed = await _waveformProfileService.ComputeAndStoreHighResAsync(
|
||||
audio.Buffer, entryKey, audio.Duration);
|
||||
if (!computed)
|
||||
{
|
||||
_logger.LogError("TriggerMixWaveform: waveform computation/storage failed for {EntryKey}", entryKey);
|
||||
|
||||
@@ -57,6 +57,7 @@ public class UnifiedTrackService
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
@@ -106,8 +107,8 @@ public class UnifiedTrackService
|
||||
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
|
||||
// on the release, not the track.
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
|
||||
// rides on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
@@ -116,6 +117,7 @@ public class UnifiedTrackService
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
Description = description,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
Medium = medium,
|
||||
@@ -156,25 +158,151 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
// Best-effort waveform profile: both stores succeeded, so the upload is a success
|
||||
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
|
||||
// Best-effort waveform datums: both stores succeeded, so the upload is a success regardless of
|
||||
// the datum outcome. A missing datum renders as a flat seekbar / blank visualizer on the
|
||||
// frontend, so a failure here is logged and swallowed — never fails the upload.
|
||||
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
|
||||
await TryStoreWaveformDatumsAsync(unpersisted.EntryKey, ct);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
|
||||
/// <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
|
||||
{
|
||||
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
|
||||
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
|
||||
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, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
_logger.LogError(ex, "ReplaceAudioAsync: waveform regen failed for {EntryKey}; replace unaffected.", entryKey);
|
||||
}
|
||||
|
||||
// Write the new duration to SQL. The vault bytes are already swapped, so this is the
|
||||
// authoritative metadata update for the replace. A failure here is surfaced (unlike the
|
||||
// best-effort waveform regen above) because a stale DurationSeconds silently corrupts
|
||||
// derived aggregates (e.g. MixRuntimeSeconds on the home stats endpoint).
|
||||
var durationWrite = await _sqlTrackService.SetDuration(trackId, newAudio.Duration, ct);
|
||||
if (!durationWrite.Success)
|
||||
{
|
||||
var error = durationWrite.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"ReplaceAudioAsync: vault swap succeeded but SQL duration update failed for track {TrackId} ({EntryKey}): {Error}",
|
||||
trackId, entryKey, error);
|
||||
return Result.CreateFailResult("Audio replaced but duration metadata could not be updated.");
|
||||
}
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
// Compute and store both waveform datums for a freshly uploaded track: the fixed 512-bucket profile
|
||||
// the player-bar seeker consumes, and the duration-derived high-res datum the lava visualizer
|
||||
// consumes (phase-12 §5 — every track now carries one, computed at upload). Both source the same
|
||||
// audio: read it back from the vault once (the authoritative parsed duration + the stored buffer)
|
||||
// rather than re-reading and re-parsing the temp file. Best-effort throughout — never fails upload.
|
||||
private async Task TryStoreWaveformDatumsAsync(string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(entryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform datum step: no audio in vault for {EntryKey} immediately after store; skipping.",
|
||||
entryKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, entryKey);
|
||||
await _waveformProfileService.ComputeAndStoreHighResAsync(audio.Buffer, entryKey, audio.Duration);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform datum step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time backfill: for every non-deleted track whose SQL duration is still null, read the
|
||||
/// processor-extracted runtime from the vault audio (by EntryKey) and write it to SQL. The migration
|
||||
/// cannot read the vault, so this runs at runtime after deploy. Idempotent — a re-run only touches
|
||||
/// rows still missing a duration. Returns (updated, skipped) counts. A per-track vault miss or SQL
|
||||
/// failure is logged and skipped, never aborting the batch.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<(int Updated, int Skipped)>> BackfillDurationsAsync(CancellationToken ct)
|
||||
{
|
||||
var missing = await _sqlTrackService.GetTracksMissingDuration(ct);
|
||||
if (!missing.Success || missing.Value is null)
|
||||
{
|
||||
var error = missing.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("BackfillDurationsAsync: failed to load tracks missing duration: {Error}", error);
|
||||
return ResultContainer<(int, int)>.CreateFailResult($"Could not load tracks: {error}");
|
||||
}
|
||||
|
||||
var updated = 0;
|
||||
var skipped = 0;
|
||||
foreach (var track in missing.Value)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var audio = await _contentTrackContentService.GetAudioBinaryAsync(track.EntryKey);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("BackfillDurationsAsync: no vault audio for {EntryKey} (track {Id}); skipping.",
|
||||
track.EntryKey, track.Id);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var write = await _sqlTrackService.UpdateDuration(track.Id, audio.Duration, ct);
|
||||
if (!write.Success)
|
||||
{
|
||||
var error = write.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogWarning("BackfillDurationsAsync: SQL update failed for track {Id}: {Error}", track.Id, error);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated++;
|
||||
}
|
||||
|
||||
_logger.LogInformation("BackfillDurationsAsync complete: {Updated} updated, {Skipped} skipped.", updated, skipped);
|
||||
return ResultContainer<(int, int)>.CreatePassResult((updated, skipped));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace DeepDrftAPI
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
InitializeMixWaveformsVault(db).GetAwaiter().GetResult();
|
||||
InitializeTrackWaveformsVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -66,12 +66,13 @@ namespace DeepDrftAPI
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the mix-waveforms vault exists. Holds high-resolution waveform datums for DJ Mix releases.
|
||||
private static async Task InitializeMixWaveformsVault(FileDatabase fileDatabase)
|
||||
// Ensure the track-waveforms vault exists. Holds the per-track high-resolution waveform datum
|
||||
// (every track — Mix, Session, Cut), keyed by the track's EntryKey.
|
||||
private static async Task InitializeTrackWaveformsVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.MixWaveforms))
|
||||
if (!fileDatabase.HasVault(VaultConstants.TrackWaveforms))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.MixWaveforms, MediaVaultType.Media);
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.TrackWaveforms, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ Multi-format support via router pattern. All processors live in `DeepDrftContent
|
||||
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
|
||||
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
|
||||
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
|
||||
- `WaveformProfileService.ComputeAndStoreHighResAsync(entryKey)`: The shared compute seam for the duration-derived high-res waveform datum (~333 samples/sec). Medium-neutral — computes for any track by `EntryKey`, stores in the `track-waveforms` vault. Called by the upload path (`UnifiedTrackService.UploadAsync` for every new track), the CMS per-row generate action, and the Mix release trigger (now a legacy delegate). Phase 12 generalization of the former Mix-only compute.
|
||||
- `WaveformResolution`: Enum / constants controlling bucket density for the high-res compute. Renamed from `MixWaveformResolution` in Phase 12.
|
||||
|
||||
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
|
||||
|
||||
@@ -120,7 +122,7 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
|
||||
|
||||
## Vault constants
|
||||
|
||||
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
|
||||
`VaultConstants.Tracks = "tracks"`, `VaultConstants.Images = "images"`, and `VaultConstants.TrackWaveforms = "track-waveforms"` — the vault names in production use. `TrackWaveforms` holds the per-track high-res waveform datum keyed by `TrackEntity.EntryKey` (Phase 12; renamed from the former `mix-waveforms`, which was Mix-only). New vault names go here when adding new vault types.
|
||||
|
||||
## Service registration
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ public static class VaultConstants
|
||||
public const string Images = "images";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for Mix high-resolution waveform datums, keyed by the mix track's EntryKey.
|
||||
/// Vault name for per-track high-resolution waveform datums, keyed by the track's EntryKey.
|
||||
/// Every track (Mix, Session, Cut) carries one — computed at upload, regenerable on demand.
|
||||
/// Distinct from WaveformProfiles (player-bar low-res); same pipeline at higher resolution.
|
||||
/// The datum resolution is duration-derived (≈333 samples/sec, see <c>WaveformResolution</c>).
|
||||
/// </summary>
|
||||
public const string MixWaveforms = "mix-waveforms";
|
||||
public const string TrackWaveforms = "track-waveforms";
|
||||
}
|
||||
@@ -3,11 +3,21 @@ namespace DeepDrftContent.Processors;
|
||||
/// <summary>
|
||||
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
|
||||
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
|
||||
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
|
||||
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// equal time slices, takes the RMS of each slice, applies a ~15 ms envelope-follower smoothing
|
||||
/// so the contour reads as a smooth curve rather than a spikey polygon, then peak-normalizes so
|
||||
/// the loudest bucket is 1. No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// </summary>
|
||||
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
{
|
||||
/// <summary>
|
||||
/// Envelope-follower time constant, seconds. ~15 ms is the smoothing target (Phase 10
|
||||
/// tuning, reduced from 50 ms which was over-smoothed): long enough to round off the
|
||||
/// per-bucket RMS spikes into a smooth ribbon contour, short enough that real loudness
|
||||
/// transients (kicks, drops) still read. Applied as a symmetric (forward+backward) one-pole
|
||||
/// filter so the smoothing introduces no time lag.
|
||||
/// </summary>
|
||||
public const double SmoothingTimeConstantSeconds = 0.005;
|
||||
|
||||
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
if (bucketCount <= 0)
|
||||
@@ -64,16 +74,28 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Envelope smoothing (~15 ms): round the spikey per-bucket RMS into a smooth contour before
|
||||
// peak-normalization, so the rendered ribbon reads as a continuous curve, not faceted polygons.
|
||||
// Each bucket spans (totalSeconds / bucketCount) of audio; the filter coefficient is derived
|
||||
// from that against the time constant so the smoothing is duration-aware, not a fixed window.
|
||||
var totalSeconds = (double)frameCount / sampleRate;
|
||||
var bucketSeconds = totalSeconds / bucketCount;
|
||||
SmoothEnvelope(result, bucketSeconds);
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +114,42 @@ public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetric one-pole envelope smoothing over the per-bucket loudness, in place. A forward pass
|
||||
/// then a backward pass cancels the single-pole phase lag, so the smoothed contour stays aligned
|
||||
/// with the audio (no rightward time shift). The coefficient <c>a = exp(−bucketSeconds / τ)</c>
|
||||
/// gives a ~<paramref name="bucketSeconds"/>-relative response targeting the ~15 ms time constant:
|
||||
/// each bucket blends <c>(1 − a)</c> of itself with <c>a</c> of the running envelope. A near-zero
|
||||
/// or non-finite bucket duration leaves the data untouched (nothing to smooth meaningfully).
|
||||
/// </summary>
|
||||
private static void SmoothEnvelope(double[] data, double bucketSeconds)
|
||||
{
|
||||
if (data.Length < 2 || bucketSeconds <= 0 || !double.IsFinite(bucketSeconds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var a = Math.Exp(-bucketSeconds / SmoothingTimeConstantSeconds);
|
||||
// a→1 means buckets are far finer than τ (heavy smoothing); a→0 means each bucket already
|
||||
// spans ≫ τ, so smoothing is a no-op. Either extreme is handled by the blend below.
|
||||
|
||||
// Forward pass.
|
||||
var env = data[0];
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
|
||||
// Backward pass (zero-phase): smooth the forward result in reverse so the net lag is zero.
|
||||
env = data[^1];
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
env = a * env + (1 - a) * data[i];
|
||||
data[i] = env;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
|
||||
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
|
||||
|
||||
@@ -42,7 +42,10 @@ public class WaveformProfileService
|
||||
/// <paramref name="entryKey"/> in <paramref name="vaultName"/> (defaults to
|
||||
/// <see cref="VaultConstants.WaveformProfiles"/> when null). Bucket resolution defaults to
|
||||
/// <see cref="WaveformProfileOptions.BucketCount"/> (512) when <paramref name="bucketCount"/> is null;
|
||||
/// pass a higher value (e.g., 2048) for the Mix high-res datum. Returns false (and logs) on any
|
||||
/// callers pass an explicit count for higher-resolution data — e.g. the per-track high-res datum
|
||||
/// derives its count from the audio duration (≈333 samples/sec, see <c>WaveformResolution</c>) so long
|
||||
/// tracks are not under-sampled. This service is content-agnostic: it captures however many buckets it is told to and
|
||||
/// does not itself decide the count. Returns false (and logs) on any
|
||||
/// failure — a missing profile is handled gracefully downstream, so callers on the upload path
|
||||
/// log-and-continue rather than failing the upload. Does not throw for expected failure modes.
|
||||
/// </summary>
|
||||
@@ -96,6 +99,24 @@ public class WaveformProfileService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a track's high-resolution loudness datum and stores it in the
|
||||
/// <see cref="VaultConstants.TrackWaveforms"/> vault keyed by <paramref name="entryKey"/>. The bucket
|
||||
/// count is duration-derived (≈333 samples/sec, clamped — see <see cref="WaveformResolution"/>) so the
|
||||
/// datum captures at a constant time resolution regardless of track length. This is the single home
|
||||
/// for "the high-res per-track datum" — the upload path, the CMS generate action, and the Mix trigger
|
||||
/// all funnel through it, so every track (Mix, Session, Cut) gets an identical datum keyed the same way.
|
||||
/// Returns false (logged) on any failure, per the content-agnostic contract above.
|
||||
/// </summary>
|
||||
public Task<bool> ComputeAndStoreHighResAsync(
|
||||
ReadOnlyMemory<byte> wavBytes,
|
||||
string entryKey,
|
||||
double durationSeconds)
|
||||
{
|
||||
var bucketCount = WaveformResolution.BucketCountForDuration(durationSeconds);
|
||||
return ComputeAndStoreAsync(wavBytes, entryKey, bucketCount, VaultConstants.TrackWaveforms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored quantized profile bytes for a track from <paramref name="vaultName"/>
|
||||
/// (defaults to <see cref="VaultConstants.WaveformProfiles"/> when null), or null if no profile
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the bucket count for a track's high-resolution loudness datum from the audio's duration, so
|
||||
/// the stored profile captures at a constant <em>time</em> resolution instead of a fixed bucket count.
|
||||
/// Applies to every track (Mix, Session, Cut) — the release is just the host (phase-12 §5).
|
||||
///
|
||||
/// Rationale (phase-9 Mix Visualizer redesign spec §F): the max-zoom window shows one quarter note
|
||||
/// at 180 BPM = 333 ms of audio, and a smooth glassy curve wants ~100+ sample points across that
|
||||
/// window. A fixed 2048-bucket datum gives fractions of a sample per 333 ms window on any real-length
|
||||
/// audio (a 30-minute mix gets ~0.38 buckets), so long content is badly under-sampled. Capturing at a
|
||||
/// constant ≈333 samples/sec (≈3 ms/sample) makes a 333 ms window hold ~111 samples regardless of
|
||||
/// length — the direct expression of "high enough resolution regardless of content length."
|
||||
///
|
||||
/// This is the orchestration-side derivation (duration → bucket count); the actual compute/store stays
|
||||
/// in <see cref="WaveformProfileService"/>, which is content-agnostic and parameterized by bucket count.
|
||||
/// </summary>
|
||||
public static class WaveformResolution
|
||||
{
|
||||
/// <summary>≈333 samples/sec (≈3 ms/sample): one quarter note at 180 BPM (333 ms) holds ~111 samples.</summary>
|
||||
public const int SamplesPerSecond = 333;
|
||||
|
||||
/// <summary>
|
||||
/// Upper cap on bucket count (~2,000,000 samples ≈ a 100-minute track at 333/s). Past this length we
|
||||
/// accept slightly-below-target density rather than an unbounded datum (spec §F mitigation #1).
|
||||
/// </summary>
|
||||
public const int MaxBucketCount = 2_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// Floor on bucket count. Keeps the historical 2048-bucket density as the minimum so a degenerate
|
||||
/// near-zero or very-short track still yields a usable profile rather than zero/handful of buckets.
|
||||
/// </summary>
|
||||
public const int MinBucketCount = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a track's duration (seconds) to a bucket count of <c>ceil(durationSeconds × 333)</c>,
|
||||
/// clamped to [<see cref="MinBucketCount"/>, <see cref="MaxBucketCount"/>]. Non-finite or negative
|
||||
/// durations fall to the floor. A 60-minute track → ~1.2M buckets; a 3-minute track → ~60k.
|
||||
/// </summary>
|
||||
public static int BucketCountForDuration(double durationSeconds)
|
||||
{
|
||||
if (double.IsNaN(durationSeconds) || durationSeconds <= 0)
|
||||
return MinBucketCount;
|
||||
|
||||
// Guard against overflow before the cast: anything at/above the cap clamps anyway.
|
||||
var raw = Math.Ceiling(durationSeconds * SamplesPerSecond);
|
||||
if (raw >= MaxBucketCount)
|
||||
return MaxBucketCount;
|
||||
|
||||
var buckets = (int)raw;
|
||||
return buckets < MinBucketCount ? MinBucketCount : buckets;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,10 @@ public class TrackContentService
|
||||
{
|
||||
EntryKey = trackId, // FileDatabase entry ID
|
||||
TrackName = trackName,
|
||||
OriginalFileName = originalFileName
|
||||
OriginalFileName = originalFileName,
|
||||
// Persist the processor-extracted runtime to SQL so aggregate queries (total mix runtime)
|
||||
// need not touch the vault. Same value the high-res waveform compute reads downstream.
|
||||
DurationSeconds = audioBinary.Duration
|
||||
};
|
||||
|
||||
return trackEntity;
|
||||
@@ -100,6 +103,84 @@ public class TrackContentService
|
||||
string? originalFileName = null) =>
|
||||
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Swaps the audio bytes for an existing track in place: processes a new audio file and
|
||||
/// re-registers it under the SAME <paramref name="entryKey"/> in the tracks vault. The track's
|
||||
/// vault key — and therefore its SQL link, release membership, position, and metadata — is
|
||||
/// untouched; only the binary changes. The new audio is written first; only on confirmed success
|
||||
/// is a stale old backing file cleaned up. A cross-format replacement (e.g. .wav → .flac) leaves
|
||||
/// the old file on disk under its former filename once the index is updated; the post-success
|
||||
/// cleanup removes it. For a same-extension overwrite the register alone suffices — the file is
|
||||
/// written in place. If the register fails the original audio is left intact and null is returned,
|
||||
/// so the track remains playable. Returns the freshly stored <see cref="AudioBinary"/> on success
|
||||
/// (so the caller can regenerate waveform data from the same bytes) — matching the FileDatabase
|
||||
/// swallow-and-return-null contract.
|
||||
/// </summary>
|
||||
public async Task<AudioBinary?> ReplaceTrackAudioAsync(string entryKey, string audioFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Capture the old extension before touching the vault. After register the index
|
||||
// will point to the new extension, so we need the old value now to detect a
|
||||
// cross-format swap and clean up the stale file post-success.
|
||||
var existing = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, entryKey);
|
||||
var oldExtension = existing?.Extension;
|
||||
|
||||
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
|
||||
if (audioBinary == null)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: processing returned null for {entryKey}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
|
||||
// Register the new audio. This upserts the index entry (new extension recorded) and
|
||||
// writes the new file to disk. If this fails the original entry and file are untouched.
|
||||
var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, entryKey, audioBinary);
|
||||
if (!success)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: vault write failed for {entryKey}; original audio preserved");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Post-success stale-file cleanup for cross-format swaps. The register wrote the new
|
||||
// file (e.g. .flac) and updated the index to the new extension, but the old backing
|
||||
// file (e.g. .wav) is now unreferenced on disk. Delete it directly by constructing the
|
||||
// old path — RemoveResourceAsync would now resolve to the new extension and delete the
|
||||
// wrong file. Non-fatal: an orphaned old file is a disk-hygiene concern, not a
|
||||
// playback issue (the index no longer references it).
|
||||
if (oldExtension != null && oldExtension != audioBinary.Extension)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault != null)
|
||||
{
|
||||
var sanitizedKey = System.Text.RegularExpressions.Regex.Replace(entryKey, @"[^a-zA-Z0-9]", "-");
|
||||
var staleFilePath = Path.Combine(vault.RootPath, $"{sanitizedKey}{oldExtension}");
|
||||
try
|
||||
{
|
||||
if (File.Exists(staleFilePath))
|
||||
File.Delete(staleFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync: stale backing-file removal failed for {entryKey} ({staleFilePath}): {ex.Message} — new audio is live; orphaned file may remain on disk");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return audioBinary;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.ReplaceTrackAudioAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
|
||||
+21
-7
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
|
||||
DeepDrftData/
|
||||
├── Data/
|
||||
│ ├── DeepDrftContext.cs # EF DbContext
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
|
||||
│ └── Configurations/
|
||||
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
|
||||
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
|
||||
@@ -32,7 +32,7 @@ DeepDrftData/
|
||||
|
||||
## EF DbContext and configuration
|
||||
|
||||
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
|
||||
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
|
||||
|
||||
`TrackConfiguration` uses EF fluent API:
|
||||
- Table name: `track` (singular)
|
||||
@@ -42,6 +42,7 @@ DeepDrftData/
|
||||
- `Album`, `Genre`: optional, max 200 / 100
|
||||
- `ReleaseDate`: optional `DateOnly`
|
||||
- `ImagePath`: optional, max 500 (currently a free-form URL string; points to images vault in future)
|
||||
- `DurationSeconds`: optional `double?` (nullable; populated at upload from vault audio; backfillable via `POST api/track/duration/backfill`; used for aggregate mix-runtime queries). Column: `duration_seconds`. Migration: `20260618155002_AddTrackDuration`.
|
||||
|
||||
## Service → Repository → DbContext shape
|
||||
|
||||
@@ -49,6 +50,19 @@ DeepDrftData/
|
||||
- **Repository** (`TrackRepository`): Internal data access. Queries the DbContext. Throws on error (service catches).
|
||||
- **DbContext** (`DeepDrftContext`): EF Core. Directly accessed by repository, never by service (pattern isolation).
|
||||
|
||||
Notable repository / service methods beyond the standard CRUD:
|
||||
|
||||
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
|
||||
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
|
||||
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
|
||||
|
||||
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
|
||||
|
||||
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity` — `EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
|
||||
|
||||
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: `CountDistinctListenersAsync()` (site-wide), `CountDistinctListenersForTrackAsync(trackEntryKey)`, `CountDistinctListenersForReleaseAsync(releaseId)` — each excludes null `anon_id` rows.
|
||||
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)` return NetBlocks `Result`. Wave 16.3 added three distinct-count members returning `ResultContainer<int>`: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated). The `anon_id` columns and covering indexes on `play_event`/`share_event` are part of this migration — no additional migration was needed for 16.3.
|
||||
|
||||
Example:
|
||||
|
||||
```csharp
|
||||
@@ -117,10 +131,10 @@ Run from the solution root:
|
||||
|
||||
```bash
|
||||
# Add a migration
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI
|
||||
|
||||
# Apply to database
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftPublic
|
||||
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
|
||||
```
|
||||
|
||||
The design-time factory means you can also run `dotnet ef ... --project DeepDrftData` standalone for local development (it doesn't need the startup project).
|
||||
@@ -132,9 +146,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
|
||||
## Connection string
|
||||
|
||||
- **DeepDrftAPI**: `environment/connections.json` → `ConnectionStrings:DefaultConnection`
|
||||
- Points at the same database (PostgreSQL in production, SQLite for local development).
|
||||
- Always PostgreSQL (Npgsql) — both production and local development.
|
||||
|
||||
The design-time factory hard-codes the local path for `dotnet ef` commands.
|
||||
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
|
||||
|
||||
## Service registration
|
||||
|
||||
@@ -142,7 +156,7 @@ In `DeepDrftAPI/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddDbContext<DeepDrftContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
|
||||
services.AddScoped<TrackRepository>();
|
||||
services.AddScoped<TrackManager>();
|
||||
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the <c>play_counter</c> rollup (Phase 16 §4.1 / D6). One row per track, unique
|
||||
/// on track_id so the incremental-on-write bump is an upsert against a single row. <c>TotalPlays</c> is
|
||||
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
|
||||
/// </summary>
|
||||
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayCounter> builder)
|
||||
{
|
||||
builder.ToTable("play_counter");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackId)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_id");
|
||||
|
||||
builder.Property(e => e.PartialCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
builder.Property(e => e.SampledCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
builder.Property(e => e.CompleteCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
// Derived headline figure — never a column.
|
||||
builder.Ignore(e => e.TotalPlays);
|
||||
|
||||
builder.HasIndex(e => e.TrackId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
|
||||
/// <c>BaseEntity</c> — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
|
||||
/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
|
||||
/// query) so the aggregation paths stay cheap as the log grows.
|
||||
/// </summary>
|
||||
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayEvent> builder)
|
||||
{
|
||||
builder.ToTable("play_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackEntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
builder.Property(e => e.Bucket)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store the readable bucket name, mirroring ReleaseMedium.
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("bucket");
|
||||
|
||||
// Reserved nullable token (wave 16.3). Same width as a stringified GUID.
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
|
||||
builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,18 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
// App-minted GUID-string public handle, configured exactly like TrackConfiguration's
|
||||
// entry_key: required, max 100, snake_case column. The unique index guarantees a release
|
||||
// resolves to one row by its public key.
|
||||
builder.Property(e => e.EntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
builder.HasIndex(e => e.EntryKey)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
builder.Property(e => e.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -35,6 +47,11 @@ public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
// Plain-text prose blurb. Generous ceiling for a paragraph; nullable (no data migration).
|
||||
builder.Property(e => e.Description)
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnName("description");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
|
||||
/// entity. Indexed on the target key so per-target share tallies stay cheap.
|
||||
/// </summary>
|
||||
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ShareEvent> builder)
|
||||
{
|
||||
builder.ToTable("share_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TargetType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("target_type");
|
||||
|
||||
builder.Property(e => e.TargetKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("target_key");
|
||||
|
||||
builder.Property(e => e.Channel)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("channel");
|
||||
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
||||
.HasColumnName("track_number")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
// Nullable: existing rows carry NULL until the one-time duration backfill populates them.
|
||||
builder.Property(e => e.DurationSeconds)
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ public class DeepDrftContext : DbContext
|
||||
public DbSet<SessionMetadata> SessionMetadata { get; set; }
|
||||
public DbSet<MixMetadata> MixMetadata { get; set; }
|
||||
|
||||
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
|
||||
// FileDatabase vault is not involved.
|
||||
public DbSet<PlayEvent> PlayEvents { get; set; }
|
||||
public DbSet<ShareEvent> ShareEvents { get; set; }
|
||||
public DbSet<PlayCounter> PlayCounters { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -23,5 +29,8 @@ public class DeepDrftContext : DbContext
|
||||
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ public interface IReleaseService
|
||||
/// <summary>Single release with both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The public addressing read: single release resolved by its opaque EntryKey (Phase 11 §3e). Both metadata navs included (nulls for non-matching media).</summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Track entry keys for a release. Single-entry for Session/Mix (enforced at upload); may be multiple for Cut.</summary>
|
||||
Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -28,6 +28,33 @@ public interface ITrackService
|
||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate figures behind the public home hero stat row: Cut track count + per-ReleaseType Cut
|
||||
/// release breakdown, Mix release count, and total Mix runtime in seconds. One read for all three cards.
|
||||
/// </summary>
|
||||
Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Non-deleted tracks whose SQL duration is still null — the work list for the one-time duration
|
||||
/// backfill. The backfill reads each track's stored duration from the vault and writes it via
|
||||
/// <see cref="UpdateDuration"/>.
|
||||
/// </summary>
|
||||
Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Set the SQL duration for one track. Idempotent: a track whose duration is already set is left
|
||||
/// untouched. Backs the duration backfill. Returns the number of rows updated (0 or 1).
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unconditionally overwrite the SQL duration for one track. Unlike <see cref="UpdateDuration"/>,
|
||||
/// this carries no null guard — it is for the replace-audio path where the track already has a
|
||||
/// non-null duration that must be overwritten with the new audio's value. Returns a fail Result
|
||||
/// when zero rows are affected (track removed between lookup and write).
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616035252_AddReleaseDescription")]
|
||||
partial class AddReleaseDescription
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseDescription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "description",
|
||||
table: "release",
|
||||
type: "character varying(4000)",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "description",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260616210143_AddReleaseEntryKey")]
|
||||
partial class AddReleaseEntryKey
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseEntryKey : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 11.H — front the int PK with an app-minted GUID-string EntryKey (Phase 11 §3e). The
|
||||
// scaffolded single non-null-with-"" add is hand-edited into the three-step backfill the
|
||||
// spec requires (§3e.5(2)): existing rows must each get a UNIQUE, non-null key, so a shared
|
||||
// "" default would collide on the unique index. Add nullable → backfill a GUID per row →
|
||||
// mark non-null. No DB default is set on the final column: new rows are app-populated by
|
||||
// FindOrCreateRelease (Guid.NewGuid().ToString()), exactly as tracks mint their EntryKey.
|
||||
|
||||
// 1) Add the column nullable so the backfill can run before the NOT NULL constraint.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
// 2) Backfill a unique GUID string per existing row. gen_random_uuid()::text yields the
|
||||
// lowercase 36-char hyphenated shape Guid.NewGuid().ToString() produces, so migrated and
|
||||
// app-minted keys are indistinguishable. Per-row evaluation → each row gets a distinct key.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"release\" SET \"entry_key\" = gen_random_uuid()::text WHERE \"entry_key\" IS NULL;");
|
||||
|
||||
// 3) Now every row is populated and unique — enforce NOT NULL.
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "entry_key",
|
||||
table: "release",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(100)",
|
||||
oldMaxLength: 100,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release",
|
||||
column: "entry_key",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_entry_key",
|
||||
table: "release");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "entry_key",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260618155002_AddTrackDuration")]
|
||||
partial class AddTrackDuration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrackDuration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "duration_seconds",
|
||||
table: "track",
|
||||
type: "double precision",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "duration_seconds",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260619155610_AddPlayShareTelemetry")]
|
||||
partial class AddPlayShareTelemetry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("TrackId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("track_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
|
||||
b.ToTable("play_counter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_play_event_anon_id");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.HasDatabaseName("IX_play_event_release_id");
|
||||
|
||||
b.HasIndex("TrackEntryKey")
|
||||
.HasDatabaseName("IX_play_event_track_entry_key");
|
||||
|
||||
b.ToTable("play_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_share_event_anon_id");
|
||||
|
||||
b.HasIndex("TargetKey")
|
||||
.HasDatabaseName("IX_share_event_target_key");
|
||||
|
||||
b.ToTable("share_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayShareTelemetry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_counter",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
complete_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_counter", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "share_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_share_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_counter_track_id",
|
||||
table: "play_counter",
|
||||
column: "track_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_anon_id",
|
||||
table: "play_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_release_id",
|
||||
table: "play_event",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_track_entry_key",
|
||||
table: "play_event",
|
||||
column: "track_entry_key");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_anon_id",
|
||||
table: "share_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_target_key",
|
||||
table: "share_event",
|
||||
column: "target_key");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_counter");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_event");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "share_event");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,94 @@ namespace DeepDrftData.Migrations
|
||||
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")
|
||||
@@ -90,6 +178,17 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
@@ -138,6 +237,10 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
@@ -194,6 +297,53 @@ namespace DeepDrftData.Migrations
|
||||
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")
|
||||
@@ -207,6 +357,10 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
||||
@@ -94,6 +94,20 @@ public class ReleaseManager : IReleaseService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetByEntryKeyAsync(string entryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _repository.GetByEntryKeyWithMetadataAsync(entryKey, cancellationToken);
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<string>>> GetTrackEntryKeysAsync(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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<></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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,16 @@ public class ReleaseRepository
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The public addressing read: resolve a release by its opaque EntryKey (Phase 11 §3e). Mirrors
|
||||
// GetByIdWithMetadataAsync but keys on the unique entry_key column — the int PK never reaches the
|
||||
// public surface. The resolved entity still carries its int Id for internal joins (track page).
|
||||
public async Task<ReleaseEntity?> GetByEntryKeyWithMetadataAsync(string entryKey, CancellationToken ct)
|
||||
=> await _context.Releases
|
||||
.Where(r => r.EntryKey == entryKey && !r.IsDeleted)
|
||||
.Include(r => r.SessionMetadata)
|
||||
.Include(r => r.MixMetadata)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// Non-deleted track counts for a specific set of releases, for populating ReleaseDto.TrackCount on
|
||||
// list reads without an N+1 fan-out. Releases with zero live tracks are absent from the dictionary.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseIdsAsync(
|
||||
|
||||
@@ -3,6 +3,7 @@ using Data.Errors;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
@@ -157,6 +158,70 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
|
||||
// Aggregate figures for the public home hero stat row, assembled in as few round-trips as is clean.
|
||||
// All counts go through Query (!t.IsDeleted) plus an explicit !t.Release.IsDeleted guard so tracks
|
||||
// under a directly-deleted release are also excluded. Mix runtime sums DurationSeconds with a
|
||||
// null-coalesce to 0 so not-yet-backfilled rows contribute zero rather than throwing or skewing the
|
||||
// total. The cut release-type breakdown is grouped here so a zero-count type is simply absent from
|
||||
// the result (no present-with-zero row).
|
||||
public async Task<HomeStatsDto> GetHomeStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var releases = _context.Set<ReleaseEntity>().Where(r => !r.IsDeleted);
|
||||
|
||||
var cutTrackCount = await Query
|
||||
.CountAsync(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Cut, ct);
|
||||
|
||||
var cutReleaseTypeCounts = await releases
|
||||
.Where(r => r.Medium == ReleaseMedium.Cut)
|
||||
.GroupBy(r => r.ReleaseType)
|
||||
.Select(g => new CutReleaseTypeCount { ReleaseType = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var mixReleaseCount = await releases
|
||||
.CountAsync(r => r.Medium == ReleaseMedium.Mix, ct);
|
||||
|
||||
var mixRuntimeSeconds = await Query
|
||||
.Where(t => t.Release != null && !t.Release.IsDeleted && t.Release.Medium == ReleaseMedium.Mix)
|
||||
.SumAsync(t => t.DurationSeconds ?? 0d, ct);
|
||||
|
||||
return new HomeStatsDto
|
||||
{
|
||||
CutTrackCount = cutTrackCount,
|
||||
CutReleaseTypeCounts = cutReleaseTypeCounts,
|
||||
MixReleaseCount = mixReleaseCount,
|
||||
MixRuntimeSeconds = mixRuntimeSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
// EntryKey + stored duration for non-deleted tracks whose SQL duration is still null — the work list
|
||||
// the one-time duration backfill iterates. The migration cannot read the vault, so duration is filled
|
||||
// at runtime: this lists which rows still need it, the backfill reads each from the vault and writes
|
||||
// it back via UpdateDurationAsync.
|
||||
public async Task<List<TrackEntity>> GetTracksMissingDurationAsync(CancellationToken ct = default)
|
||||
=> await Query.Where(t => t.DurationSeconds == null).ToListAsync(ct);
|
||||
|
||||
// Set-based duration write for one track (no load round-trip), used by the backfill. The
|
||||
// DurationSeconds == null guard keeps a re-run from re-stamping updated_at on an already-filled row
|
||||
// and from clobbering a value the upload path may have set in the meantime.
|
||||
public async Task<int> UpdateDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Id == id && t.DurationSeconds == null)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.DurationSeconds, durationSeconds)
|
||||
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
|
||||
|
||||
// Unconditional duration overwrite for one track (no load round-trip), used by the replace-audio
|
||||
// path. Unlike UpdateDurationAsync, there is no null guard — replace always overwrites the
|
||||
// existing value because a normally-uploaded track already has a non-null DurationSeconds and the
|
||||
// null-guarded backfill query would match zero rows and silently leave it stale. Returns the count
|
||||
// of rows affected; zero means the track was removed between the GetById lookup and this write.
|
||||
public async Task<int> SetDurationAsync(long id, double durationSeconds, CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Id == id)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.DurationSeconds, durationSeconds)
|
||||
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
|
||||
|
||||
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||
// signalling the manager to create one. Soft-deleted releases never match.
|
||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||
@@ -211,6 +276,7 @@ public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
target.TrackName = source.TrackName;
|
||||
target.TrackNumber = source.TrackNumber;
|
||||
target.OriginalFileName = source.OriginalFileName;
|
||||
target.DurationSeconds = source.DurationSeconds;
|
||||
target.ReleaseId = source.ReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
EntryKey = entity.EntryKey,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
Description = entity.Description,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
Medium = entity.Medium,
|
||||
@@ -50,11 +52,16 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
// Round-trips the public handle. On the create path (FindOrCreateRelease) the DTO carries no
|
||||
// EntryKey yet, so that path overrides this with a freshly minted GUID — the same shape as the
|
||||
// natural-key (Title/Artist) override there.
|
||||
EntryKey = dto.EntryKey,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
UpdatedAt = dto.UpdatedAt,
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
Description = dto.Description,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
Medium = dto.Medium,
|
||||
@@ -73,6 +80,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
TrackName = entity.TrackName,
|
||||
OriginalFileName = entity.OriginalFileName,
|
||||
TrackNumber = entity.TrackNumber,
|
||||
DurationSeconds = entity.DurationSeconds,
|
||||
ReleaseId = entity.ReleaseId,
|
||||
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||
};
|
||||
@@ -89,6 +97,7 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
TrackName = model.TrackName,
|
||||
OriginalFileName = model.OriginalFileName,
|
||||
TrackNumber = model.TrackNumber,
|
||||
DurationSeconds = model.DurationSeconds,
|
||||
ReleaseId = model.ReleaseId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +177,9 @@ public class TrackManager
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
var entity = TrackConverter.Convert(releaseData);
|
||||
entity.Id = 0;
|
||||
// Mint the public EntryKey app-side at creation — the identical call tracks make in
|
||||
// TrackContentService (Phase 11 §3e.4). The incoming DTO carries no key on the create path.
|
||||
entity.EntryKey = Guid.NewGuid().ToString();
|
||||
entity.Title = title;
|
||||
entity.Artist = artist;
|
||||
|
||||
@@ -233,6 +236,61 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<HomeStatsDto>> GetHomeStats(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = await Repository.GetHomeStatsAsync(cancellationToken);
|
||||
return ResultContainer<HomeStatsDto>.CreatePassResult(stats);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<HomeStatsDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<TrackDto>>> GetTracksMissingDuration(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = await Repository.GetTracksMissingDurationAsync(cancellationToken);
|
||||
return ResultContainer<List<TrackDto>>.CreatePassResult(
|
||||
entities.Select(TrackConverter.Convert).ToList());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<TrackDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> UpdateDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await Repository.UpdateDurationAsync(id, durationSeconds, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(updated);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> SetDuration(long id, double durationSeconds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var affected = await Repository.SetDurationAsync(id, durationSeconds, cancellationToken);
|
||||
if (affected == 0)
|
||||
return ResultContainer<int>.CreateFailResult($"Duration write matched no rows for track {id}.");
|
||||
return ResultContainer<int>.CreatePassResult(affected);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
||||
{
|
||||
try
|
||||
@@ -281,6 +339,7 @@ public class TrackManager
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.Description = release.Description;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.Medium = release.Medium;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using DeepDrftShared.Client.Common
|
||||
|
||||
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
||||
<MudPopoverProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
||||
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
</MudAppBar>
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Small"
|
||||
Class="d-flex flex-column justify-center align-center"
|
||||
Style="min-height: calc(100vh - 48px);">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -12,9 +12,9 @@
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="Back to site">
|
||||
<MudTooltip Text="Catalogue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Home"
|
||||
Href="/"
|
||||
Href="/catalogue"
|
||||
Color="Color.Inherit" />
|
||||
</MudTooltip>
|
||||
</MudAppBar>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@page "/"
|
||||
@layout Layout.CmsHomeLayout
|
||||
|
||||
<PageTitle>Deep Drft — Admin</PageTitle>
|
||||
|
||||
<HierarchicalRoleAuthorizeView>
|
||||
<Authorized>
|
||||
<RedirectToCatalogue />
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="my-8">
|
||||
<MudImage Fluid="true" Src="img/cms-hero.webp" Alt="Deep Drft" />
|
||||
<MudText Typo="Typo.subtitle1" Align="Align.Center" Class="text-uppercase mud-text-secondary">
|
||||
Catalogue Management
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Href="@LoginHref"
|
||||
Class="mt-4"
|
||||
Style="min-width: 200px;">
|
||||
Login
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</NotAuthorized>
|
||||
</HierarchicalRoleAuthorizeView>
|
||||
|
||||
@code {
|
||||
private static readonly string LoginHref =
|
||||
$"/account/login?returnUrl={Uri.EscapeDataString("catalogue")}";
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/"
|
||||
@page "/catalogue"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
@layout Layout.CmsLayout
|
||||
@inject NavigationManager Nav
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
@@ -12,114 +13,91 @@
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
|
||||
</MudItem>
|
||||
@foreach (var card in Cards)
|
||||
{
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard(card)
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private bool _tracksLoading = true;
|
||||
private bool _albumsLoading = true;
|
||||
private bool _genresLoading = true;
|
||||
// One card per release medium. Each deep-links to /releases with the medium tab pre-selected via the
|
||||
// same ?medium= convention the Add Track buttons use. The count is that medium's release total.
|
||||
private sealed record MediumCard(ReleaseMedium Medium, string Label, string Icon, Color Color);
|
||||
|
||||
private int? _trackCount;
|
||||
private int? _albumCount;
|
||||
private int? _genreCount;
|
||||
private static readonly IReadOnlyList<MediumCard> Cards = new[]
|
||||
{
|
||||
new MediumCard(ReleaseMedium.Cut, "CUTS", Icons.Material.Filled.Album, Color.Primary),
|
||||
new MediumCard(ReleaseMedium.Session, "SESSIONS", Icons.Material.Filled.Mic, Color.Secondary),
|
||||
new MediumCard(ReleaseMedium.Mix, "MIXES", Icons.Material.Filled.GraphicEq, Color.Tertiary),
|
||||
};
|
||||
|
||||
// Medium → release count (null while loading or on failure). Each medium's count is one cheap paged
|
||||
// read (pageSize 1) for its TotalCount, run concurrently.
|
||||
private readonly Dictionary<ReleaseMedium, int?> _counts = new();
|
||||
private readonly HashSet<ReleaseMedium> _loading = Cards.Select(c => c.Medium).ToHashSet();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
|
||||
// finally block so its card updates as soon as its own fetch returns.
|
||||
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
|
||||
// Each loader calls StateHasChanged in its finally block so its card updates as soon as its own
|
||||
// fetch returns, rather than blocking on the slowest of the three.
|
||||
await Task.WhenAll(Cards.Select(c => LoadCountAsync(c.Medium)));
|
||||
}
|
||||
|
||||
private async Task LoadTrackCount()
|
||||
private async Task LoadCountAsync(ReleaseMedium medium)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetTrackCountAsync();
|
||||
_trackCount = result.Success ? result.Value : null;
|
||||
// pageSize 1 — we only need TotalCount, not the rows. Sort column is required by the API but
|
||||
// immaterial to the count.
|
||||
var result = await CmsReleaseService.GetPagedAsync(
|
||||
medium, page: 1, pageSize: 1, sortColumn: "Title", sortDescending: false);
|
||||
_counts[medium] = result.Success && result.Value is not null ? result.Value.TotalCount : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard track count failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
Logger.LogWarning("Dashboard {Medium} count failed: {Error}",
|
||||
medium, result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tracksLoading = false;
|
||||
_loading.Remove(medium);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAlbumCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetReleasesAsync();
|
||||
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard album summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_albumsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadGenreCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetGenreSummariesAsync();
|
||||
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_genresLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
|
||||
private RenderFragment SummaryCard(MediumCard card) => __builder =>
|
||||
{
|
||||
var loading = _loading.Contains(card.Medium);
|
||||
var count = _counts.GetValueOrDefault(card.Medium);
|
||||
<MudCard Elevation="8" Style="height: 100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
|
||||
<MudIcon Icon="@card.Icon" Color="@card.Color" Size="Size.Large" />
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
|
||||
<MudProgressCircular Color="@card.Color" Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
|
||||
<MudText Typo="Typo.h3" Color="@card.Color">@(count?.ToString() ?? "—")</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@card.Label</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="justify-center pb-4">
|
||||
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo("/tracks"))">
|
||||
<MudButton Variant="Variant.Text" Color="@card.Color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo(ReleasesHref(card.Medium)))">
|
||||
View
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
};
|
||||
|
||||
// Deep-link to the Releases page with this medium's tab pre-selected. Mirrors the ?medium= seed the
|
||||
// Add Track buttons use; the Releases page reads it to set the active tab.
|
||||
private static string ReleasesHref(ReleaseMedium medium) =>
|
||||
$"/releases?medium={medium.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField Value="Description" ValueChanged="@((string v) => DescriptionChanged.InvokeAsync(v))"
|
||||
T="string" Label="Description" Lines="4" MaxLength="4000"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@@ -79,6 +84,8 @@
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> DescriptionChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/tracks/album/{AlbumName}/edit"
|
||||
@page "/tracks/{TrackId:long}/edit"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@@ -7,7 +8,6 @@
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -32,6 +32,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -58,14 +59,19 @@
|
||||
single-track medium, mirroring BatchUpload's same-named collapse. Cut keeps the full list. *@
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
@* ExistingTrackCount counts edit-session persisted rows (Id.HasValue), not authoritative
|
||||
live release count — acceptable because this gate only hides a UI control; the
|
||||
TrySoftDeleteEmptyReleaseAsync backstop remains the authoritative guard. *@
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
AllowNewTracks="@(_medium == ReleaseMedium.Cut)"
|
||||
ExistingTrackCount="@_tracks.Count(t => t.Id.HasValue)"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
OnRemove="RemoveRow"
|
||||
OnReplaceFileSelected="HandleReplaceFileSelected" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
@@ -85,7 +91,7 @@
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_saving">
|
||||
Cancel
|
||||
</MudButton>
|
||||
@@ -108,11 +114,18 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 2_000_000_000L;
|
||||
|
||||
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
// Track-id addressing (Track-mode per-row Edit, §8.M): loads the addressed track's parent
|
||||
// release and pre-selects that track's row, so editing a single Cut track lands the admin on
|
||||
// the track they clicked rather than on the release with no row context. Null for the
|
||||
// release-title route. The two routes are mutually exclusive — only one segment binds.
|
||||
[Parameter] public long? TrackId { get; set; }
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _loading = true;
|
||||
@@ -129,6 +142,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -154,12 +168,35 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Track-addressed entry (§8.M): resolve the addressed track to its parent release title,
|
||||
// then fall through to the shared release-load path below. The clicked track's id is held
|
||||
// for row pre-selection once the list is built.
|
||||
var albumName = AlbumName;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var trackResult = await CmsTrackService.GetByIdAsync(trackId);
|
||||
if (!trackResult.Success || trackResult.Value is not { } track)
|
||||
{
|
||||
_loadError = trackResult.Messages.FirstOrDefault()?.Message ?? "Track not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
albumName = track.Release?.Title;
|
||||
if (string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
_loadError = "This track has no parent release to edit.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: AlbumName);
|
||||
album: albumName);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
@@ -171,15 +208,16 @@
|
||||
var tracks = result.Value.Items.ToList();
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_loadError = $"No tracks found for release '{AlbumName}'.";
|
||||
_loadError = $"No tracks found for release '{albumName}'.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
_albumName = AlbumName;
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_description = release?.Description ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_medium = release?.Medium ?? ReleaseMedium.Cut;
|
||||
@@ -203,10 +241,24 @@
|
||||
_tracks.RemoveRange(1, _tracks.Count - 1);
|
||||
}
|
||||
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
// Track-addressed entry pre-selects the clicked row (§8.M Option 2). For a multi-track Cut
|
||||
// the addressed track may be any ordinal; for single-track media it is always row 0 (the
|
||||
// collapse above leaves one row). Fall back to row 0 if the id is absent or trimmed away.
|
||||
_selectedIndex = ResolveInitialSelection();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private int ResolveInitialSelection()
|
||||
{
|
||||
if (_tracks.Count == 0) return -1;
|
||||
if (TrackId is { } trackId)
|
||||
{
|
||||
var addressed = _tracks.FindIndex(t => t.Id == trackId);
|
||||
if (addressed >= 0) return addressed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
@@ -275,6 +327,85 @@
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private async Task HandleReplaceFileSelected((int Index, IBrowserFile File) picked)
|
||||
{
|
||||
var (index, file) = picked;
|
||||
if (index < 0 || index >= _tracks.Count) return;
|
||||
|
||||
var row = _tracks[index];
|
||||
if (!row.Id.HasValue)
|
||||
{
|
||||
// Defensive: replace is only offered on persisted rows. A new row would have no track to
|
||||
// swap against — it takes the upload path on save instead.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"'{file.Name}' is not a .wav file.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Replace audio",
|
||||
$"Replace the audio for '{row.TrackName}' with '{file.Name}'? " +
|
||||
"Metadata stays the same; the waveform is regenerated for the new audio.",
|
||||
yesText: "Replace", cancelText: "Cancel");
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
row.UploadedBytes = 0;
|
||||
row.TotalBytes = file.Size;
|
||||
row.ErrorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await using var wavStream = file.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
var lastPercent = -1;
|
||||
var progress = new Progress<long>(written =>
|
||||
{
|
||||
row.UploadedBytes = written;
|
||||
if (row.UploadPercent != lastPercent)
|
||||
{
|
||||
lastPercent = row.UploadPercent;
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
|
||||
var result = await CmsTrackService.ReplaceTrackAudioAsync(
|
||||
row.Id.Value, wavStream, file.Size, file.Name, file.ContentType, progress);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
Snackbar.Add($"Replace failed: {error}", Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Reset to Queued (not Done): a Done row is skipped by SaveAsync, but the admin may
|
||||
// still want to save pending metadata edits. The audio swap is already persisted.
|
||||
row.Status = BatchRowStatus.Queued;
|
||||
row.OriginalFileName = file.Name;
|
||||
Snackbar.Add($"Replaced audio for '{row.TrackName}'.", Severity.Success);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Replace audio failed for track id {Id}", row.Id);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Replace failed — please try again.";
|
||||
Snackbar.Add("Replace failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveCover()
|
||||
{
|
||||
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
||||
@@ -337,6 +468,7 @@
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
var description = string.IsNullOrWhiteSpace(_description) ? null : _description;
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name editor is shown. Sync here so changes to the Release Name always
|
||||
@@ -401,6 +533,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
@@ -426,21 +559,40 @@
|
||||
{
|
||||
// New track — upload, then link cover art with a follow-up update (same
|
||||
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
row.UploadedBytes = 0;
|
||||
row.TotalBytes = row.WavFile!.Size;
|
||||
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
// Re-render only on whole-percent change so a large upload paints ~100 frames,
|
||||
// not thousands. Progress<T> marshals back onto the renderer dispatcher.
|
||||
var lastPercent = -1;
|
||||
var progress = new Progress<long>(written =>
|
||||
{
|
||||
row.UploadedBytes = written;
|
||||
if (row.UploadPercent != lastPercent)
|
||||
{
|
||||
lastPercent = row.UploadPercent;
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
|
||||
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Size,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium);
|
||||
_medium,
|
||||
progress);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
{
|
||||
@@ -463,6 +615,7 @@
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
@@ -471,7 +624,7 @@
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
||||
// Non-blocking: track persisted; cover can be re-linked by re-editing.
|
||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||
row.TrackName, uploadResult.Value.Id);
|
||||
}
|
||||
@@ -494,15 +647,10 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Either branch changed catalogue data, so the browse caches are stale regardless of
|
||||
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
|
||||
// album and genre lists re-fetch.
|
||||
VM.Invalidate();
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/tracks/albums");
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -27,6 +27,17 @@ public class BatchRowModel
|
||||
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>Bytes pushed to the wire so far for this row's in-flight upload. Reset per attempt.</summary>
|
||||
public long UploadedBytes { get; set; }
|
||||
|
||||
/// <summary>Total payload bytes for this row (the WAV file size), the progress denominator.</summary>
|
||||
public long TotalBytes { get; set; }
|
||||
|
||||
/// <summary>Upload completion as a 0–100 percent, or 0 when the total is unknown.</summary>
|
||||
public int UploadPercent => TotalBytes > 0
|
||||
? (int)Math.Clamp(UploadedBytes * 100 / TotalBytes, 0, 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
||||
|
||||
@@ -21,7 +21,7 @@ else
|
||||
{
|
||||
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">Use the Replace audio action in the list to swap this track's audio.</MudText>
|
||||
</MudField>
|
||||
}
|
||||
else
|
||||
|
||||
@@ -34,13 +34,45 @@
|
||||
Disabled="@(index == Tracks.Count - 1 || Disabled)"
|
||||
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
|
||||
aria-label="Move track down" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="@Disabled"
|
||||
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||
aria-label="Remove track" />
|
||||
@* Replace audio: existing (persisted) rows only. New rows still pick their WAV
|
||||
via the file input above, so a replace control there would be redundant. A
|
||||
native <label for> drives a per-row hidden InputFile — clicking the icon
|
||||
opens that row's picker with zero JS (no eval, no programmatic .click()). *@
|
||||
@if (row.Id.HasValue)
|
||||
{
|
||||
<label for="@ReplaceInputId(index)" @onclick:stopPropagation="true"
|
||||
style="display: inline-flex; @(Disabled ? "pointer-events: none; opacity: 0.5;" : "cursor: pointer;")">
|
||||
<MudIcon Icon="@Icons.Material.Filled.SwapHoriz"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
aria-label="Replace audio" />
|
||||
</label>
|
||||
<InputFile id="@ReplaceInputId(index)"
|
||||
OnChange="@(e => OnReplaceFileSelected.InvokeAsync((index, e.File)))"
|
||||
accept=".wav,audio/wav,audio/x-wav"
|
||||
disabled="@Disabled"
|
||||
style="display: none;" />
|
||||
}
|
||||
@* Remove: hidden for the sole remaining persisted track so a release can never
|
||||
be track-deleted down to zero (that path soft-deletes the whole release). New
|
||||
rows are always removable — dropping one only discards a pending upload. *@
|
||||
@if (!(row.Id.HasValue && ExistingTrackCount <= 1))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="@Disabled"
|
||||
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||
aria-label="Remove track" />
|
||||
}
|
||||
</MudStack>
|
||||
@if (row.Status == BatchRowStatus.Uploading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Info"
|
||||
Value="@row.UploadPercent"
|
||||
Class="mx-2 mb-2"
|
||||
aria-label="@($"Uploading {row.TrackName}")" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudList>
|
||||
@@ -53,11 +85,28 @@
|
||||
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNewTracks { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Count of existing (persisted, Id-bearing) tracks in the list. When this is 1, the remove
|
||||
/// control on the sole persisted row is suppressed so a release cannot be track-deleted to zero
|
||||
/// (replace + release-level delete remain). New unsaved rows are excluded from this count.
|
||||
/// </summary>
|
||||
[Parameter] public int ExistingTrackCount { get; set; }
|
||||
|
||||
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the admin picks a replacement WAV for an existing row, carrying the list index and
|
||||
/// the chosen file. Only fired for persisted (Id-bearing) rows.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<(int Index, IBrowserFile File)> OnReplaceFileSelected { get; set; }
|
||||
|
||||
// Stable per-row DOM id linking the swap-icon <label> to its hidden InputFile.
|
||||
private static string ReplaceInputId(int index) => $"replace-audio-input-{index}";
|
||||
|
||||
private const int MaxFilesPerPick = 50;
|
||||
|
||||
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
@@ -21,6 +20,7 @@
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-Description="_description"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
Medium="_medium"
|
||||
@@ -63,6 +63,12 @@
|
||||
{
|
||||
@* Track name is derived from the Release Name for Session/Mix — no separate input. *@
|
||||
<MudText Typo="Typo.caption">Selected: @(_tracks[0].WavFile?.Name ?? "—")</MudText>
|
||||
@if (_tracks[0].Status == BatchRowStatus.Uploading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Info"
|
||||
Value="@_tracks[0].UploadPercent"
|
||||
aria-label="Uploading track" />
|
||||
}
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -80,7 +86,7 @@
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
|
||||
OnClick="@(() => Navigation.NavigateTo("/releases"))"
|
||||
Disabled="_uploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
@@ -102,9 +108,9 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
private const long MaxUploadBytes = 2_000_000_000L;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
@@ -126,6 +132,7 @@
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
@@ -328,26 +335,47 @@
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
row.UploadedBytes = 0;
|
||||
row.TotalBytes = row.WavFile!.Size;
|
||||
|
||||
try
|
||||
{
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
// service wraps it in ProgressStreamContent so the whole file is never materialised
|
||||
// in memory before DeepDrftAPI receives it, and reports bytes-on-the-wire back here.
|
||||
await using var wavStream = row.WavFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
// Progress ticks fire ~once per 80 KB; re-render only when the whole-percent changes
|
||||
// so a half-gig upload paints ~100 frames, not thousands. Progress<T> marshals the
|
||||
// callback onto the component's renderer dispatcher, so StateHasChanged is safe here.
|
||||
var lastPercent = -1;
|
||||
var progress = new Progress<long>(written =>
|
||||
{
|
||||
row.UploadedBytes = written;
|
||||
if (row.UploadPercent != lastPercent)
|
||||
{
|
||||
lastPercent = row.UploadPercent;
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Size,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium);
|
||||
_medium,
|
||||
progress);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
@@ -360,7 +388,7 @@
|
||||
else
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
|
||||
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||
if (_imagePath is { } imgPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
@@ -369,6 +397,7 @@
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_description) ? null : _description,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
@@ -377,7 +406,7 @@
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
|
||||
// Non-blocking: track is persisted; cover art can be re-linked by editing.
|
||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||
row.TrackName, result.Value.Id);
|
||||
}
|
||||
@@ -454,8 +483,7 @@
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
Navigation.NavigateTo("/releases");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -33,6 +33,10 @@ else
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">@column.Header</MudTh>
|
||||
}
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -64,11 +68,14 @@ else
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
@foreach (var column in SpecialColumns)
|
||||
{
|
||||
@* One dedicated cell per host-declared special-action column (Mix waveform, Session hero).
|
||||
The Cell fragment recovers its typed row state via the host's RowFor lookup. Sits between
|
||||
Tracks and Actions so the universal Edit/Delete stay rightmost. *@
|
||||
<MudTd DataLabel="@column.Header">@column.Cell(context.Release)</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="Actions">
|
||||
@* Medium-specific row action (Session hero, Mix waveform) when a host supplies one;
|
||||
the ALL tab supplies none. Rendered before the shared edit/delete so the medium
|
||||
affordance reads left-to-right ahead of the universal actions. *@
|
||||
@RowActions?.Invoke(context.Release)
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
@@ -88,7 +95,7 @@ else
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
<MudTd colspan="@ColumnCount" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
@@ -107,10 +114,71 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
@* Per-track waveform-datum status + generate (migrated from the retired
|
||||
CmsTrackGrid). The expanded child row is the releases view's only
|
||||
per-track surface, so the unique per-track Profile / High-res columns
|
||||
live here. *@
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Profile</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">High-res</MudTh>
|
||||
@* Info column: per-track EntryKey + OriginalFileName tooltip (migrated
|
||||
from the retired CmsTrackGrid's .cms-track-info monospace block). *@
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
<MudTd DataLabel="Profile">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (HasProfile(track.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Profile generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@(HasProfile(track.EntryKey) ? "Regenerate profile" : "Generate profile")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generating.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateProfileAsync(track))" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="High-res">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (HasHighRes(track.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="High-res datum generated">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@(HasHighRes(track.EntryKey) ? "Regenerate high-res datum" : "Generate high-res datum")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Waves"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@_generatingHighRes.Contains(track.EntryKey)"
|
||||
OnClick="@(() => GenerateHighResAsync(track))" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
@* Per-track info tooltip (restored from the retired CmsTrackGrid's
|
||||
.cms-track-info monospace block): EntryKey + OriginalFileName. *@
|
||||
<MudTd>
|
||||
<MudTooltip Placement="Placement.Left">
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.EntryKey</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(track.OriginalFileName))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Style="font-family: monospace;">@track.OriginalFileName</MudText>
|
||||
}
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
@@ -126,12 +194,39 @@ else
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
// Optional per-row, medium-specific action slot (Session hero upload, Mix waveform generate),
|
||||
// rendered in the Actions cell ahead of the shared edit/delete buttons. The ALL tab leaves it
|
||||
// unset and renders the grid exactly as before. A per-medium host (CmsCut/Session/MixBrowser)
|
||||
// supplies it so the rich grid filtered to one medium keeps that medium's bespoke affordance —
|
||||
// the rich expand/delete/Type-chip/edit logic stays here, single-sourced, rather than forked.
|
||||
[Parameter] public RenderFragment<ReleaseDto>? RowActions { get; set; }
|
||||
/// <summary>
|
||||
/// Fires after any per-row waveform generate (profile or high-res) succeeds. The parent page
|
||||
/// wires this to its own <c>RefreshWaveformStatusAsync</c> so its missing-count badges stay
|
||||
/// current after an individual-row generate inside an expanded album row.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached per-track waveform status so the next row expand re-fetches fresh data
|
||||
/// from the API. Called by the parent page after a catalogue-wide bulk run so already-expanded
|
||||
/// rows reflect the new state on the next expand interaction.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync()
|
||||
{
|
||||
_profileStatus = null;
|
||||
_highResStatus = null;
|
||||
StateHasChanged();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Zero or more dedicated, header-labelled special-action columns (Session hero upload, Mix waveform
|
||||
// generate), each rendered as its own header cell + per-row cell between the Tracks and Actions
|
||||
// columns. The ALL and Cut tabs leave this empty and render exactly as before — only the standard
|
||||
// columns plus Edit/Delete. A per-medium host supplies its bespoke affordances here so the rich
|
||||
// expand/delete/Type-chip/edit logic stays single-sourced in this grid rather than forked.
|
||||
[Parameter] public IReadOnlyList<SpecialActionColumn> SpecialColumns { get; set; } = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
// Base columns: expand, Art, Album, Artist, Genre, Release Date, Type, Tracks, Actions = 9.
|
||||
private const int BaseColumnCount = 9;
|
||||
|
||||
// Total rendered columns, driving the expanded child-row colspan so it always spans the full table
|
||||
// regardless of how many special-action columns the host declared.
|
||||
private int ColumnCount => BaseColumnCount + SpecialColumns.Count;
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
@@ -167,6 +262,103 @@ else
|
||||
[ReleaseMedium.Mix] = "DJ Mix",
|
||||
};
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes for the expanded-row per-track waveform columns (migrated from
|
||||
// the retired CmsTrackGrid). Loaded once per grid instance on first row expand; a per-row generate
|
||||
// flips a single entry to true. Null until first loaded.
|
||||
private Dictionary<string, bool>? _profileStatus;
|
||||
private Dictionary<string, bool>? _highResStatus;
|
||||
private readonly HashSet<string> _generating = new();
|
||||
private readonly HashSet<string> _generatingHighRes = new();
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_profileStatus is not null && _profileStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
private bool HasHighRes(string entryKey) =>
|
||||
_highResStatus is not null && _highResStatus.TryGetValue(entryKey, out var has) && has;
|
||||
|
||||
// Fetch the catalogue-wide waveform status once and cache it. The admin catalogue is small (one unpaged
|
||||
// call covers it), and per-track status only matters for rows the admin actually expands.
|
||||
private async Task EnsureWaveformStatusAsync()
|
||||
{
|
||||
if (_profileStatus is not null) return;
|
||||
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
_profileStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile);
|
||||
_highResStatus = result.Value.ToDictionary(s => s.EntryKey, s => s.HasHighRes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Leave both empty (not null) so we do not re-fetch on every expand after a transient failure;
|
||||
// the next OnReleasesChanged refresh path will rebuild the grid and retry.
|
||||
_profileStatus = new Dictionary<string, bool>();
|
||||
_highResStatus = new Dictionary<string, bool>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateProfileAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_profileStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateHighResAsync(TrackDto track)
|
||||
{
|
||||
_generatingHighRes.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
(_highResStatus ??= new())[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated high-res datum for '{track.TrackName}'.", Severity.Success);
|
||||
await OnWaveformGenerated.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"High-res generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generatingHighRes.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
@@ -175,6 +367,9 @@ else
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
// The per-track Profile / High-res columns need waveform status for the rows just loaded.
|
||||
// Loaded once for the catalogue on first expand and cached for this grid instance.
|
||||
await EnsureWaveformStatusAsync();
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
own data load so a host (TrackList today, the 8.A tab strip later) renders it with no parameters and
|
||||
no VM plumbing. Re-loads on first render and re-fetches after a row mutation so the list stays in
|
||||
sync with the catalogue. *@
|
||||
<CmsAlbumBrowser Releases="_releases"
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="_releases"
|
||||
IsLoading="_loading"
|
||||
OnReleasesChanged="OnGridReleasesChanged" />
|
||||
OnReleasesChanged="OnGridReleasesChanged"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
// Fires after a row mutation (delete) so a host can invalidate sibling caches derived from the same
|
||||
@@ -18,9 +20,23 @@
|
||||
// notification, not the data source. Optional: an embed that has no sibling state leaves it unset.
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
private IReadOnlyList<ReleaseDto> _releases = Array.Empty<ReleaseDto>();
|
||||
private bool _loading = true;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override Task OnInitializedAsync() => ReloadAsync();
|
||||
|
||||
private async Task OnGridReleasesChanged()
|
||||
|
||||
@@ -4,19 +4,36 @@
|
||||
|
||||
@* CUTS tab content (Phase 9 §8.A/§8.C): the rich CmsAlbumBrowser grid filtered to Cut releases, so the
|
||||
tab carries expand-tracks, delete, the Type chip, and per-row edit identically to the ALL tab — no
|
||||
forked grid. Cuts have no medium-specific row action, so no RowActions slot is supplied; the grid
|
||||
renders its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
<CmsAlbumBrowser Releases="Releases"
|
||||
forked grid. Cuts have no medium-specific action, so no SpecialColumns are supplied; the grid renders
|
||||
its shared edit/delete only. Embedded as tab content only; no standalone @page route. *@
|
||||
<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync" />
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated" />
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Cut;
|
||||
protected override string MediumNoun => "cuts";
|
||||
|
||||
protected override CutRow ToRow(ReleaseDto release) => new() { Release = release };
|
||||
protected override ReleaseDto ReleaseOf(CutRow row) => row.Release;
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
public sealed class CutRow
|
||||
{
|
||||
public required ReleaseDto Release { get; set; }
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (Genres.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
@foreach (var genre in Genres)
|
||||
{
|
||||
var isExpanded = ExpandedGenre == genre.Genre;
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="@(isExpanded ? 4 : 1)"
|
||||
Style="cursor: pointer;"
|
||||
@onclick="@(() => ToggleGenre(genre.Genre))">
|
||||
<div class="@SwatchClass(isExpanded)"></div>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@genre.Genre</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@if (ExpandedGenre is not null)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
|
||||
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public string? ExpandedGenre { get; set; }
|
||||
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
|
||||
|
||||
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
|
||||
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
|
||||
private async Task ToggleGenre(string genre) =>
|
||||
await OnExpandedGenreChanged.InvokeAsync(genre);
|
||||
|
||||
private static string SwatchClass(bool isExpanded) =>
|
||||
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.cms-genre-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cms-genre-swatch--active {
|
||||
background-color: var(--mud-palette-primary-hover);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
/// it (§8.C parity — reuse, don't fork). This base owns the loading flag, the medium-filtered load, the
|
||||
/// per-release row projection, and a cover-thumbnail helper; subclasses supply the <see cref="Medium"/>,
|
||||
/// an error noun, and their bespoke per-row action (Session hero upload, Mix waveform generate) via the
|
||||
/// rich grid's <c>RowActions</c> slot, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// rich grid's <c>SpecialColumns</c> column model, looking their action-state row up with <see cref="RowFor"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRow">The subclass's row model wrapping a <see cref="ReleaseDto"/> plus its
|
||||
/// medium-specific action state (upload/generate flags). The rich grid renders from the bare
|
||||
@@ -42,8 +42,8 @@ public abstract class CmsMediumBrowserBase<TRow> : ComponentBase where TRow : cl
|
||||
// it never sees TRow. Rebuilt on every (re)load so the grid re-projects against a fresh reference.
|
||||
protected IReadOnlyList<ReleaseDto> Releases { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
|
||||
// release.Id → action-state row, so a RowActions fragment (which the grid hands a ReleaseDto) can
|
||||
// recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
|
||||
// release.Id → action-state row, so a SpecialColumns cell delegate (which the grid hands a ReleaseDto)
|
||||
// can recover its TRow. Rebuilt alongside Rows so a refresh never leaves a stale row behind.
|
||||
private Dictionary<long, TRow> _rowsById = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
@* Embedded as the MIXES tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/mixes for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Mixes (§8.C
|
||||
parity: expand-tracks, delete, Type chip, per-row edit), with the Mix waveform generate supplied as
|
||||
its medium-specific RowActions slot so that affordance survives the move off the thin table. When
|
||||
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
|
||||
suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved in both. *@
|
||||
its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The waveform affordance (9.5.E) is preserved
|
||||
in both. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
@@ -22,9 +23,9 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Mixes</MudText>
|
||||
@@ -40,9 +41,24 @@ else
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Mix;
|
||||
protected override string MediumNoun => "mixes";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override MixRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
@@ -52,16 +68,33 @@ else
|
||||
protected override ReleaseDto ReleaseOf(MixRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The waveform generate is the Mix's
|
||||
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
|
||||
// matching MixRow's generate state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
// both branches above render the same markup without duplication. The Mix declares one dedicated
|
||||
// "Waveform" special-action column; the grid renders it between Tracks and Actions, handing the cell
|
||||
// each release, and RowFor recovers the matching MixRow's generate state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[] { new SpecialActionColumn("Waveform", WaveformCell) };
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the dedicated "Waveform" column: status icon plus generate/regenerate button with
|
||||
// progress. Recovers the typed MixRow via RowFor; skips rendering for a release not on the page.
|
||||
private RenderFragment<ReleaseDto> WaveformCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@if (row.HasWaveform)
|
||||
{
|
||||
<MudTooltip Text="Waveform generated">
|
||||
@@ -89,9 +122,9 @@ else
|
||||
<span>@(row.HasWaveform ? "Regenerate" : "Generate")</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
</MudStack>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task GenerateWaveformAsync(MixRow row)
|
||||
{
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
@* Embedded as the SESSIONS tab content of the Release Archive (Phase 9 §8.A), and still routable at
|
||||
/tracks/sessions for direct-URL access. The grid is the rich CmsAlbumBrowser filtered to Sessions
|
||||
(§8.C parity: expand-tracks, delete, Type chip, per-row edit), with the Session hero upload supplied
|
||||
as its medium-specific RowActions slot so that affordance survives the move off the thin table. When
|
||||
embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive" button) is
|
||||
suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in both contexts. *@
|
||||
as its medium-specific special-action column so that affordance survives the move off the thin table.
|
||||
When embedded, the page chrome (title, container, the now-meaningless "Back to Release Archive"
|
||||
button) is suppressed; the standalone route keeps it. The hero affordance (9.5.E) is preserved in
|
||||
both contexts. *@
|
||||
@if (Embedded)
|
||||
{
|
||||
@GridContent
|
||||
@@ -23,9 +24,9 @@ else
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks/archive"
|
||||
Href="/releases"
|
||||
Class="mb-4">
|
||||
Back to Release Archive
|
||||
Back to Releases
|
||||
</MudButton>
|
||||
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Sessions</MudText>
|
||||
@@ -41,9 +42,24 @@ else
|
||||
/// </summary>
|
||||
[Parameter] public bool Embedded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forwarded from the inner <see cref="CmsAlbumBrowser"/>: fires after any per-row waveform
|
||||
/// generate succeeds so the parent page can refresh its catalogue-wide missing-count badges.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnWaveformGenerated { get; set; }
|
||||
|
||||
private CmsAlbumBrowser? _albumBrowser;
|
||||
|
||||
protected override ReleaseMedium Medium => ReleaseMedium.Session;
|
||||
protected override string MediumNoun => "sessions";
|
||||
|
||||
/// <summary>
|
||||
/// Clears the inner grid's cached per-track waveform status so the next row expand re-fetches.
|
||||
/// Called by the parent page after a catalogue-wide bulk run.
|
||||
/// </summary>
|
||||
public Task InvalidateWaveformStatusAsync() =>
|
||||
_albumBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask;
|
||||
|
||||
protected override SessionRow ToRow(ReleaseDto release) => new()
|
||||
{
|
||||
Release = release,
|
||||
@@ -53,48 +69,74 @@ else
|
||||
protected override ReleaseDto ReleaseOf(SessionRow row) => row.Release;
|
||||
|
||||
// The grid itself — identical in the embedded and standalone contexts. Defined once as a fragment so
|
||||
// both branches above render the same markup without duplication. The hero upload is the Session's
|
||||
// medium-specific RowActions content; the grid hands it each release, and RowFor recovers the
|
||||
// matching SessionRow's upload state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser Releases="Releases"
|
||||
// both branches above render the same markup without duplication. The Session declares one dedicated
|
||||
// "Hero" special-action column; the grid renders it between Tracks and Actions, handing the cell each
|
||||
// release, and RowFor recovers the matching SessionRow's upload state.
|
||||
private RenderFragment GridContent => @<CmsAlbumBrowser @ref="_albumBrowser"
|
||||
Releases="Releases"
|
||||
IsLoading="Loading"
|
||||
OnReleasesChanged="ReloadAsync">
|
||||
<RowActions Context="release">
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
OnReleasesChanged="ReloadAsync"
|
||||
OnWaveformGenerated="OnWaveformGenerated"
|
||||
SpecialColumns="_specialColumns" />;
|
||||
|
||||
// Allocated once per component instance in OnInitialized (field initializers cannot reference
|
||||
// instance members, so initialization is deferred to the first lifecycle hook).
|
||||
private IReadOnlyList<SpecialActionColumn> _specialColumns = Array.Empty<SpecialActionColumn>();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_specialColumns = new[]
|
||||
{
|
||||
new SpecialActionColumn("Hero", HeroThumbCell),
|
||||
new SpecialActionColumn("", HeroButtonCell),
|
||||
};
|
||||
base.OnInitialized();
|
||||
}
|
||||
|
||||
// Per-row cell for the "Hero" thumbnail column: just the image preview div.
|
||||
private RenderFragment<ReleaseDto> HeroThumbCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
@if (row.HeroImageEntryKey is { Length: > 0 } heroKey)
|
||||
{
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
<div class="cms-album-thumb" style="background-image: url('@ThumbUrl(heroKey)');"></div>
|
||||
}
|
||||
</RowActions>
|
||||
</CmsAlbumBrowser>;
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
}
|
||||
</text>;
|
||||
|
||||
// Per-row cell for the "Hero Image" upload button column: set/replace upload button with progress.
|
||||
private RenderFragment<ReleaseDto> HeroButtonCell => release =>@<text>
|
||||
@{ var row = RowFor(release); }
|
||||
@if (row is not null)
|
||||
{
|
||||
<MudFileUpload T="IBrowserFile"
|
||||
Accept="image/*"
|
||||
FilesChanged="@(file => UploadHeroAsync(row, file))"
|
||||
Disabled="@row.IsUploading">
|
||||
<ActivatorContent>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Image"
|
||||
Disabled="@row.IsUploading">
|
||||
@if (row.IsUploading)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Uploading…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(row.HeroImageEntryKey is { Length: > 0 } ? "Replace hero" : "Set hero")</span>
|
||||
}
|
||||
</MudButton>
|
||||
</ActivatorContent>
|
||||
</MudFileUpload>
|
||||
}
|
||||
</text>;
|
||||
|
||||
private async Task UploadHeroAsync(SessionRow row, IBrowserFile? file)
|
||||
{
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsTrackGrid> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (ShowAddButton)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/upload">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="@PageSize"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
|
||||
{
|
||||
<div class="cms-track-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Waveform">
|
||||
@if (HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
<MudTooltip>
|
||||
<TooltipContent>
|
||||
<div class="cms-track-info">
|
||||
<div>Entry: @context.EntryKey</div>
|
||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
@if (!HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate Waveform">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
||||
OnClick="@(() => GenerateOneAsync(context))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AlbumFilter { get; set; }
|
||||
[Parameter] public string? GenreFilter { get; set; }
|
||||
[Parameter] public bool ShowAddButton { get; set; } = true;
|
||||
[Parameter] public int PageSize { get; set; } = 20;
|
||||
[Parameter] public EventCallback OnTracksChanged { get; set; }
|
||||
[Parameter] public EventCallback OnStatusLoaded { get; set; }
|
||||
|
||||
private MudTable<TrackDto>? _table;
|
||||
|
||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
||||
private Dictionary<string, bool> _waveformStatus = new();
|
||||
private readonly HashSet<string> _generating = new();
|
||||
|
||||
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||
private bool _bulkRunning;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private static string ThumbUrl(string imagePath) =>
|
||||
$"/api/image/{Uri.EscapeDataString(imagePath)}";
|
||||
|
||||
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
|
||||
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
|
||||
/// the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
public async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
StateHasChanged();
|
||||
await OnStatusLoaded.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
|
||||
public void SetBulkRunning(bool running)
|
||||
{
|
||||
_bulkRunning = running;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
pageNumber, state.PageSize, sortColumn, sortDescending,
|
||||
AlbumFilter, GenreFilter, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
await OnTracksChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOneAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
_waveformStatus[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.cms-track-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-track-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.cms-track-info {
|
||||
font-family: monospace;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
@page "/releases"
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<Releases> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Releases — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Releases</MudText>
|
||||
|
||||
@* Catalogue-wide waveform backfill (migrated from the retired /tracks view). Both buttons act over
|
||||
every track's waveform status — independent of any single grid — so the page owns the status map
|
||||
directly: it computes the missing counts and re-fetches after a run. No grid reference involved. *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingProfileCount == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Profiles (@MissingProfileCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Waves"
|
||||
Disabled="@(_bulkRunning || _highResBulkRunning || MissingHighResCount == 0)"
|
||||
OnClick="GenerateAllMissingHighResAsync">
|
||||
@if (_highResBulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Backfilling @_highResBulkDone / @_highResBulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Backfill High-res (@MissingHighResCount)</span>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Medium tab strip: an ALL tab plus one explicit MudTabPanel per ReleaseMedium, ALL left-most. Each
|
||||
panel is hand-declared in markup (not enum-driven) so @ref captures of the per-tab grid components
|
||||
are possible. Adding a future medium requires a hand-added MudTabPanel; its position in markup must
|
||||
match ReleaseMedium enum order, since the ?medium= deep-link seed and ActiveMedium getter are
|
||||
position-based (panel 0 = ALL, panels 1.. = enum values in order). *@
|
||||
@* Medium-aware Add Track: the button reflects the active tab and pre-selects the upload form to that
|
||||
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a seed
|
||||
only — the upload form's selector stays user-changeable after landing. *@
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@AddTrackHref(ActiveMedium)">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||
@bind-ActivePanelIndex="_activeTabIndex">
|
||||
<MudTabPanel Text="ALL">
|
||||
<CmsAllReleasesGrid @ref="_allGrid"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Cut]">
|
||||
<CmsCutBrowser @ref="_cutBrowser"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Session]">
|
||||
<CmsSessionBrowser @ref="_sessionBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="@MediumTabLabels[ReleaseMedium.Mix]">
|
||||
<CmsMixBrowser @ref="_mixBrowser"
|
||||
Embedded="true"
|
||||
OnWaveformGenerated="RefreshWaveformStatusAsync" />
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// Active tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in order. Seeded
|
||||
// from the ?medium= query param so the catalogue cards can deep-link straight to a medium's tab.
|
||||
private int _activeTabIndex;
|
||||
|
||||
// Optional deep-link target from the catalogue cards (?medium=session selects the Sessions tab) and the
|
||||
// seed for the Add Track button on the ALL tab. Read once on init; the user can switch tabs freely after.
|
||||
[SupplyParameterFromQuery(Name = "medium")] public string? MediumParam { get; set; }
|
||||
|
||||
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut; each
|
||||
// medium tab maps to its enum value by position, so a fourth medium tab gets a correct Add Track for
|
||||
// free — no markup fork.
|
||||
private ReleaseMedium ActiveMedium =>
|
||||
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
|
||||
|
||||
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
|
||||
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
|
||||
private static string AddTrackHref(ReleaseMedium medium) =>
|
||||
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
|
||||
|
||||
// Medium → tab label. The one place medium display text lives for the tab strip. The ALL tab is
|
||||
// rendered separately (it is not a medium). Tabs are explicit markup so @ref captures work.
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Cut] = "CUTS",
|
||||
[ReleaseMedium.Session] = "SESSIONS",
|
||||
[ReleaseMedium.Mix] = "MIXES",
|
||||
};
|
||||
|
||||
// @ref handles for the per-tab grids. Used to (a) invalidate their cached per-track waveform status
|
||||
// after a page-level bulk run, and (b) to wire OnWaveformGenerated so per-row generates bubble up
|
||||
// and refresh the page-level missing-count badges. Tabs are now explicit markup rather than the
|
||||
// former enum-driven MediumGrid() switch so @ref captures are possible.
|
||||
private CmsAllReleasesGrid? _allGrid;
|
||||
private CmsCutBrowser? _cutBrowser;
|
||||
private CmsSessionBrowser? _sessionBrowser;
|
||||
private CmsMixBrowser? _mixBrowser;
|
||||
|
||||
// EntryKey → HasProfile / HasHighRes, loaded once on init so the bulk buttons can show accurate missing
|
||||
// counts without depending on any rendered grid. Re-fetched after each bulk run so the counts settle.
|
||||
private IReadOnlyList<WaveformStatusDto> _waveformStatus = Array.Empty<WaveformStatusDto>();
|
||||
|
||||
private int MissingProfileCount => _waveformStatus.Count(s => !s.HasProfile);
|
||||
private int MissingHighResCount => _waveformStatus.Count(s => !s.HasHighRes);
|
||||
|
||||
// Local state for the parent-owned "Generate All Profiles" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
// Local state for the "Backfill High-res" bulk run. Independent of the profile bulk above.
|
||||
private bool _highResBulkRunning;
|
||||
private int _highResBulkTotal;
|
||||
private int _highResBulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed the active tab from ?medium= so a catalogue card deep-links straight to its medium. Panel 0
|
||||
// is ALL; a recognised medium maps to its 1-based position. Unrecognised/absent falls through to ALL.
|
||||
if (!string.IsNullOrWhiteSpace(MediumParam)
|
||||
&& Enum.TryParse<ReleaseMedium>(MediumParam, ignoreCase: true, out var medium)
|
||||
&& Enum.IsDefined(medium))
|
||||
{
|
||||
_activeTabIndex = Array.IndexOf(Enum.GetValues<ReleaseMedium>(), medium) + 1;
|
||||
}
|
||||
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<WaveformStatusDto>();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Invalidates the cached per-track waveform status on all embedded grids so the next row expand
|
||||
// re-fetches fresh data. Called after each catalogue-wide bulk run so already-expanded rows
|
||||
// reflect the new waveform state on the next expand interaction.
|
||||
private async Task InvalidateAllGridsAsync()
|
||||
{
|
||||
var tasks = new[]
|
||||
{
|
||||
_allGrid?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_cutBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_sessionBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
_mixBrowser?.InvalidateWaveformStatusAsync() ?? Task.CompletedTask,
|
||||
};
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill does not
|
||||
/// flood the API with concurrent WAV decodes. On completion, re-reads the status map so the missing
|
||||
/// count settles.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill the per-track high-res visualizer datum for every track missing one, one request at a time
|
||||
/// so a large backfill does not flood the API with concurrent WAV decodes. Re-runnable (a second run
|
||||
/// re-reads status and only retries what is still missing). On completion, re-reads the status map.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingHighResAsync()
|
||||
{
|
||||
var missing = _waveformStatus.Where(s => !s.HasHighRes).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_highResBulkRunning = true;
|
||||
_highResBulkTotal = missing.Count;
|
||||
_highResBulkDone = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateHighResWaveformAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "High-res waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_highResBulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_highResBulkRunning = false;
|
||||
await RefreshWaveformStatusAsync();
|
||||
await InvalidateAllGridsAsync();
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Backfilled {succeeded} high-res datum(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@* Session-medium fields. When AllowHeroUpload is true (BatchUpload create path), the hero image
|
||||
file picker is shown — the file is held by the parent and POSTed after the release is created.
|
||||
When false (edit paths: BatchEdit, TrackEdit, TrackNew), the original guidance alert is rendered
|
||||
When false (edit path: BatchEdit), the original guidance alert is rendered
|
||||
instead, directing the admin to the Sessions browser per-row replace action. This gate prevents
|
||||
a dead/inert control on edit forms that do not wire the hero callbacks. *@
|
||||
@if (AllowHeroUpload)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A dedicated, header-labelled grid column for a medium-specific row affordance (e.g. Mix waveform
|
||||
/// generate, Session hero upload) in <see cref="CmsAlbumBrowser"/>. A per-medium host declares zero or
|
||||
/// more of these; the grid renders one extra header cell and one extra per-row cell for each, positioned
|
||||
/// between the Tracks column and the universal Actions (Edit/Delete) column. The <see cref="Cell"/>
|
||||
/// fragment is handed each release; the host recovers its typed row state via its own RowFor lookup.
|
||||
/// </summary>
|
||||
/// <param name="Header">Column header label (e.g. "Waveform", "Hero").</param>
|
||||
/// <param name="Cell">Per-row cell content for a given release.</param>
|
||||
public sealed record SpecialActionColumn(string Header, RenderFragment<ReleaseDto> Cell);
|
||||
@@ -1,322 +1,16 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<TrackEdit> Logger
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Edit Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudButton Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||
Href="/tracks"
|
||||
Class="mb-4">
|
||||
Back to tracks
|
||||
</MudButton>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
}
|
||||
else if (_track is null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">
|
||||
Track not found.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudField Label="Entry Key" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body1" Style="font-family: monospace;">@_track.EntryKey</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">
|
||||
Vault reference — not editable.
|
||||
</MudText>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_form.TrackName"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track name is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Artist"
|
||||
Label="Artist"
|
||||
Required="true"
|
||||
RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Album"
|
||||
Label="Album"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudTextField @bind-Value="_form.Genre"
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MediumFields @bind-Medium="_form.Medium" @bind-ReleaseType="_form.ReleaseType" Disabled="_busy" />
|
||||
|
||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
||||
Label="Track Number"
|
||||
Min="1"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (ImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Cover art preview"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Clear cover art" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
|
||||
@if (_selectedImageFile is { } selected)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudDatePicker @bind-Date="_form.ReleaseDate"
|
||||
Label="Release Date"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_busy"
|
||||
OnClick="ConfirmDelete">
|
||||
Delete
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
Disabled="_busy || !CanSave"
|
||||
OnClick="SaveAsync">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track edit form is retired. Its edit responsibility is absorbed by
|
||||
BatchEdit's track-addressed entry (/tracks/{id}/edit), which loads the parent release and
|
||||
pre-selects the addressed track's row. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/{id} lands on the live edit surface; CmsTrackGrid's per-row Edit targets the new route
|
||||
directly. *@
|
||||
|
||||
@code {
|
||||
[Parameter] public long Id { get; set; }
|
||||
|
||||
private TrackDto? _track;
|
||||
private TrackEditForm _form = new();
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
|
||||
private bool CanSave =>
|
||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
||||
|
||||
// Relative path — resolves against the Manager's own origin, proxied by ImageProxyController.
|
||||
private string? ImagePreviewUrl =>
|
||||
string.IsNullOrEmpty(_form.ImagePath)
|
||||
? null
|
||||
: $"/api/image/{Uri.EscapeDataString(_form.ImagePath)}";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
var result = await CmsTrackService.GetByIdAsync(Id);
|
||||
_track = result.Success ? result.Value : null;
|
||||
if (_track is not null)
|
||||
{
|
||||
_form = TrackEditForm.From(_track);
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_track is null || !CanSave) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
// Upload any newly picked cover art first; abort the save if it fails so we never
|
||||
// persist metadata pointing at an image that was never stored.
|
||||
if (_selectedImageFile is { } file)
|
||||
{
|
||||
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var uploadResult = await CmsTrackService.UploadImageAsync(
|
||||
imageStream, file.Name, file.ContentType);
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
_form.ImagePath = uploadResult.Value;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
|
||||
// loads the authoritative row and applies these fields. imagePath is tri-state: an
|
||||
// explicit empty string clears the link, a value sets it.
|
||||
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
||||
var updated = await CmsTrackService.UpdateAsync(
|
||||
Id, _form.TrackName, _form.Artist,
|
||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
||||
_form.ReleaseType,
|
||||
_form.Medium,
|
||||
_form.TrackNumber);
|
||||
if (updated.Success)
|
||||
{
|
||||
// Album/genre browse lists derive from this track's metadata; drop their cache so
|
||||
// the /tracks browser re-fetches fresh data on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = updated.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Save failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Save failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Save failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_form.ImagePath = null;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_track is null) return;
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Delete track",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Deleting a track can empty or alter a release; drop the browse cache so the
|
||||
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackEditForm
|
||||
{
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
// Drives ReleaseType visibility via MediumFields and is persisted on save (PUT api/track/meta
|
||||
// carries Medium). A non-Cut medium resets ReleaseType to its default server-side.
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
{
|
||||
TrackName = track.TrackName,
|
||||
Artist = track.Release?.Artist ?? string.Empty,
|
||||
Album = track.Release?.Title,
|
||||
Genre = track.Release?.Genre,
|
||||
ImagePath = track.Release?.ImagePath,
|
||||
ReleaseDate = track.Release?.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null,
|
||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||
Medium = track.Release?.Medium ?? ReleaseMedium.Cut,
|
||||
TrackNumber = track.TrackNumber
|
||||
};
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo($"/tracks/{Id}/edit", replace: true);
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
@page "/tracks"
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
@page "/tracks/archive"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@* Top-level browse dimension. The former three-way toggle (Tracks / Releases / Release Archive)
|
||||
collapsed to two (§8.A): "Releases" now hosts the in-page medium tab strip below, subsuming both
|
||||
the old Releases grid (as the ALL tab) and the retired Release Archive cards. *@
|
||||
<MudToggleGroup T="BrowseMode"
|
||||
Value="VM.Mode"
|
||||
ValueChanged="OnModeChanged"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Class="mb-4">
|
||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Albums)
|
||||
{
|
||||
@* The Release Archive tab strip (§8.A): an ALL tab plus one tab per ReleaseMedium, ALL left-most.
|
||||
The medium tabs are enum-driven — a fourth medium adds a tab automatically; only a label-lookup
|
||||
entry (MediumTabLabels) and a content arm (MediumGrid) are needed, no markup fork. Selecting a
|
||||
tab swaps the grid below in place; no navigation to a separate page occurs. *@
|
||||
@* Medium-aware Add Track (§8.E): the button lives in the tab-strip chrome (not inside any grid
|
||||
component — 8.C owns those) and reflects the active tab. It pre-selects the upload form to the
|
||||
tab's medium via a single query-param (?medium=…); the ALL tab defaults to Cut. The medium is a
|
||||
seed only — the upload form's selector stays user-changeable after landing. *@
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="@AddTrackHref(ActiveMedium)">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pt-4"
|
||||
@bind-ActivePanelIndex="_activeTabIndex">
|
||||
<MudTabPanel Text="ALL">
|
||||
<CmsAllReleasesGrid OnReleasesChanged="OnAlbumsChanged" />
|
||||
</MudTabPanel>
|
||||
@foreach (var medium in Enum.GetValues<ReleaseMedium>())
|
||||
{
|
||||
<MudTabPanel Text="@MediumTabLabels[medium]">
|
||||
@MediumGrid(medium)
|
||||
</MudTabPanel>
|
||||
}
|
||||
</MudTabs>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Genre browse keeps its route (/tracks/genres) but lost its tab to Release Archive (§3.1).
|
||||
Reachable by direct URL; no longer in the toggle group. *@
|
||||
<CmsGenreBrowser Genres="VM.Genres"
|
||||
IsLoading="VM.GenresLoading"
|
||||
ExpandedGenre="@VM.ExpandedGenre"
|
||||
OnExpandedGenreChanged="OnExpandedGenreChanged" />
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private CmsTrackGrid? _grid;
|
||||
|
||||
// Active Release-Archive tab. Panel 0 is ALL; panels 1.. map to Enum.GetValues<ReleaseMedium>() in
|
||||
// order. Drives the medium-aware Add Track button (§8.E).
|
||||
private int _activeTabIndex;
|
||||
|
||||
// The medium the Add Track button pre-selects for the active tab. ALL (panel 0) defaults to Cut
|
||||
// (Daniel, 2026-06-13); each medium tab maps to its enum value by position, so a fourth medium tab
|
||||
// gets a correct Add Track for free — no markup fork.
|
||||
private ReleaseMedium ActiveMedium =>
|
||||
_activeTabIndex <= 0 ? ReleaseMedium.Cut : Enum.GetValues<ReleaseMedium>()[_activeTabIndex - 1];
|
||||
|
||||
// Single query-param convention: the upload page reads ?medium=… and seeds its selector (which stays
|
||||
// user-changeable). Always explicit, including ALL→cut, so the link is unambiguous.
|
||||
private static string AddTrackHref(ReleaseMedium medium) =>
|
||||
$"/tracks/upload?medium={medium.ToString().ToLowerInvariant()}";
|
||||
|
||||
// Medium → tab label. The one place medium display text lives for the tab strip; a future medium adds
|
||||
// one entry here and surfaces a tab automatically. Mirrors the extension discipline the retired
|
||||
// ReleaseArchiveBrowser used for its cards. The ALL tab is rendered separately (it is not a medium).
|
||||
private static readonly IReadOnlyDictionary<ReleaseMedium, string> MediumTabLabels =
|
||||
new Dictionary<ReleaseMedium, string>
|
||||
{
|
||||
[ReleaseMedium.Cut] = "CUTS",
|
||||
[ReleaseMedium.Session] = "SESSIONS",
|
||||
[ReleaseMedium.Mix] = "MIXES",
|
||||
};
|
||||
|
||||
// Medium → embedded grid. Each medium's grid is its own component (Cut has no per-row action; Session
|
||||
// carries hero upload; Mix carries waveform generation), so the content dispatch is a per-medium
|
||||
// mapping by nature — but it is a single switch returning a fragment, not a markup fork. The browsers
|
||||
// render Embedded so their standalone page chrome (container, title, back button) is suppressed here.
|
||||
private RenderFragment MediumGrid(ReleaseMedium medium) => medium switch
|
||||
{
|
||||
ReleaseMedium.Cut => @<CmsCutBrowser />,
|
||||
ReleaseMedium.Session => @<CmsSessionBrowser Embedded="true" />,
|
||||
ReleaseMedium.Mix => @<CmsMixBrowser Embedded="true" />,
|
||||
_ => @<MudText Typo="Typo.body1" Class="mt-4">No grid for this medium.</MudText>
|
||||
};
|
||||
|
||||
// The all-releases grid refreshes its own list after a delete; this notification lets us invalidate
|
||||
// the VM's genre cache so genre counts reflect the deletion on the next switch into Genre mode.
|
||||
private void OnAlbumsChanged()
|
||||
{
|
||||
VM.Invalidate();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// /tracks/archive and /tracks/albums both land on the Releases view (the tab strip); the old
|
||||
// separate Archive mode is retired (§8.A) but the route stays reachable rather than 404ing.
|
||||
var uri = NavigationManager.Uri;
|
||||
var initial =
|
||||
uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/archive", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase) ? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
await VM.SwitchModeAsync(initial);
|
||||
}
|
||||
|
||||
private async Task OnModeChanged(BrowseMode mode)
|
||||
{
|
||||
await VM.SwitchModeAsync(mode);
|
||||
var path = mode switch
|
||||
{
|
||||
BrowseMode.Albums => "/tracks/albums",
|
||||
BrowseMode.Genres => "/tracks/genres",
|
||||
_ => "/tracks"
|
||||
};
|
||||
NavigationManager.NavigateTo(path, replace: true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnExpandedGenreChanged(string? genre)
|
||||
{
|
||||
VM.SetExpandedGenre(genre);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
|
||||
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
|
||||
/// status map so the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (!statusResult.Success || statusResult.Value is null)
|
||||
{
|
||||
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
_grid?.SetBulkRunning(true);
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
_grid?.SetBulkRunning(false);
|
||||
|
||||
if (_grid is not null)
|
||||
{
|
||||
await _grid.RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +1,12 @@
|
||||
@page "/tracks/new"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ICmsReleaseService CmsReleaseService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Add Track</MudText>
|
||||
|
||||
<MudPaper Class="pa-6" Elevation="2">
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.subtitle1">WAV file</MudText>
|
||||
<InputFile OnChange="OnFileSelected" accept=".wav,audio/wav,audio/x-wav" />
|
||||
@if (_selectedFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
Selected: @_selectedFile.Name (@FormatBytes(_selectedFile.Size))
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudTextField @bind-Value="_trackName" Label="Track Name" Required="true" RequiredError="Track Name is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||
|
||||
<MediumFields @bind-Medium="_medium" @bind-ReleaseType="_releaseType" Disabled="_isUploading" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (_selectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_isUploading"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
|
||||
@if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on save.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Spacing="2" Justify="Justify.FlexEnd">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="Cancel"
|
||||
Disabled="_isUploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="_isUploading">
|
||||
@if (_isUploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
@* §8.M: the legacy single-track add form is retired. Its add responsibility is absorbed by
|
||||
BatchUpload's single-track branch. This route is kept only as a redirect so any bookmarked
|
||||
/tracks/new lands on the live upload surface; there are no in-app callers. *@
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string _trackName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _album = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
private string? _errorMessage;
|
||||
private bool _isUploading;
|
||||
|
||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedFile = e.File;
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_selectedImageFile = null;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (_selectedFile is null)
|
||||
{
|
||||
_errorMessage = "Please select a WAV file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_selectedFile.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_errorMessage = "Selected file must be a .wav file.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_trackName))
|
||||
{
|
||||
_errorMessage = "Track Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [HierarchicalRoleAuthorize(Admin)], so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art first; abort the submit if it fails so we never
|
||||
// create a track expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var fileStream = _selectedFile.OpenReadStream(MaxUploadBytes);
|
||||
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
fileStream,
|
||||
_selectedFile.Name,
|
||||
_selectedFile.ContentType,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
_selectedFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber: 1,
|
||||
_medium);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Mix uploads fire the server-side high-res waveform trigger (§3.4) — the CMS computes
|
||||
// nothing. Non-blocking: a failed trigger is recoverable from the Mixes browser.
|
||||
if (_medium == ReleaseMedium.Mix && result.Value?.ReleaseId is { } mixReleaseId)
|
||||
{
|
||||
var waveformResult = await CmsReleaseService.GenerateMixWaveformAsync(mixReleaseId);
|
||||
if (!waveformResult.Success)
|
||||
{
|
||||
Logger.LogWarning("TrackNew: mix waveform trigger failed for release {ReleaseId}", mixReleaseId);
|
||||
Snackbar.Add("Mix uploaded, but waveform generation failed. Retry from the Mixes browser.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with a
|
||||
// follow-up metadata update — same two-step pattern TrackEdit uses.
|
||||
if (_imagePath is { } imgPath && result.Value is { } created)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
created.Id,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath);
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Track was created; image is in the vault but unlinked. Non-blocking —
|
||||
// the user can attach it via Edit.
|
||||
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Upload failed: {error}";
|
||||
Logger.LogWarning("CMS upload rejected: {Error}", error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Upload failed in TrackNew");
|
||||
_errorMessage = "Upload failed. Please try again.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
Navigation.NavigateTo("/tracks");
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
protected override void OnInitialized() =>
|
||||
Navigation.NavigateTo("/tracks/upload", replace: true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo("/catalogue", forceLoad: false, replace: true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,9 +27,6 @@ builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
// DeepDrftAPI api/release family. Same no-in-process-data-layer posture as ICmsTrackService.
|
||||
builder.Services.AddScoped<ICmsReleaseService, CmsReleaseService>();
|
||||
|
||||
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
|
||||
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
|
||||
|
||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
||||
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
|
||||
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||
@@ -44,8 +41,10 @@ builder.Services.AddHttpClient("DeepDrft.Content", client =>
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
});
|
||||
|
||||
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's vault delete).
|
||||
// API key baked into the default request headers so callers need not add it manually.
|
||||
// Named HttpClient for ApiKey-protected Content API calls (CmsTrackService's non-upload operations:
|
||||
// delete, paged list, metadata read/write, waveform jobs, releases, genres).
|
||||
// Timeout left at the default 100s — these are short request/response pairs and an infinite timeout
|
||||
// would hang an InteractiveServer circuit forever on a dead connection.
|
||||
var contentApiKey = builder.Configuration["Api:ContentApiKey"]
|
||||
?? throw new InvalidOperationException("Api:ContentApiKey is required");
|
||||
builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
|
||||
@@ -54,6 +53,17 @@ builder.Services.AddHttpClient("DeepDrft.Content.Cms", client =>
|
||||
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
|
||||
});
|
||||
|
||||
// Dedicated upload client — inherits the API key but removes the whole-request timeout.
|
||||
// Large WAV uploads (several hundred MB) outrun the 100s default. The upload path enforces an
|
||||
// idle/heartbeat deadline instead (body-streaming phase via ProgressStreamContent) plus a separate
|
||||
// response-wait budget (CmsTrackService), so the client itself must not impose a total cap.
|
||||
builder.Services.AddHttpClient("DeepDrft.Content.Cms.Upload", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(contentApiUrl);
|
||||
client.DefaultRequestHeaders.Add("ApiKey", contentApiKey);
|
||||
client.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
|
||||
// Reverse-proxy support (nginx in production).
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>The browse dimensions for the /tracks page.</summary>
|
||||
public enum BrowseMode
|
||||
{
|
||||
Tracks,
|
||||
|
||||
/// <summary>The release view — hosts the medium tab strip (ALL · CUTS · SESSIONS · MIXES, §8.A).</summary>
|
||||
Albums,
|
||||
|
||||
Genres,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
|
||||
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
|
||||
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
|
||||
/// state here.
|
||||
/// </summary>
|
||||
public class CmsTrackBrowserViewModel
|
||||
{
|
||||
private readonly ICmsTrackService _trackService;
|
||||
|
||||
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
|
||||
{
|
||||
_trackService = trackService;
|
||||
}
|
||||
|
||||
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
|
||||
|
||||
// Genre mode.
|
||||
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
|
||||
public bool GenresLoading { get; private set; }
|
||||
public string? ExpandedGenre { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active mode, lazily loading the genre dataset on first entry into Genre mode and
|
||||
/// collapsing any expanded genre row. Track mode and the all-releases grid (Albums mode) each own
|
||||
/// their own data — the grid loads itself (see <c>CmsAllReleasesGrid</c>) — so no fetch happens for
|
||||
/// either here.
|
||||
/// </summary>
|
||||
public async Task SwitchModeAsync(BrowseMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
ExpandedGenre = null; // collapse on mode switch
|
||||
|
||||
if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
|
||||
{
|
||||
GenresLoading = true;
|
||||
var result = await _trackService.GetGenreSummariesAsync();
|
||||
Genres = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<GenreSummaryDto>();
|
||||
GenresLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
|
||||
public void SetExpandedGenre(string? genre)
|
||||
{
|
||||
ExpandedGenre = ExpandedGenre == genre ? null : genre;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the cached genre dataset so the next <see cref="SwitchModeAsync"/> into Genre mode
|
||||
/// re-fetches from the API. Call after a track or release mutation (edit, delete) since the genre
|
||||
/// summaries are derived from the catalogue and go stale on any such change. The all-releases grid
|
||||
/// owns and refreshes its own data, so it needs no invalidation here.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Genres = Array.Empty<GenreSummaryDto>();
|
||||
}
|
||||
}
|
||||
@@ -18,46 +18,70 @@ namespace DeepDrftManager.Services;
|
||||
public class CmsTrackService : ICmsTrackService
|
||||
{
|
||||
private const string ContentCmsClientName = "DeepDrft.Content.Cms";
|
||||
private const string UploadClientName = "DeepDrft.Content.Cms.Upload";
|
||||
private const string UploadPath = "api/track/upload";
|
||||
|
||||
// Idle/heartbeat window: abort an upload only after this long with zero bytes written to the wire.
|
||||
// The window resets on every progress tick, so a slow-but-moving half-gig upload never trips it;
|
||||
// a genuinely stalled socket does. Governs the BODY-STREAMING phase only.
|
||||
// Operator-tunable via Upload:IdleTimeoutSeconds.
|
||||
private const int DefaultIdleTimeoutSeconds = 90;
|
||||
|
||||
// Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor
|
||||
// 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
|
||||
// response-wait phase so a fully-uploaded file is never killed mid-persist.
|
||||
// Operator-tunable via Upload:ResponseTimeoutSeconds.
|
||||
private const int DefaultResponseTimeoutSeconds = 1200; // 20 minutes
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<CmsTrackService> _logger;
|
||||
private readonly TimeSpan _uploadIdleTimeout;
|
||||
private readonly TimeSpan _uploadResponseTimeout;
|
||||
|
||||
public CmsTrackService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<CmsTrackService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
var idleSeconds = configuration.GetValue<int?>("Upload:IdleTimeoutSeconds") ?? DefaultIdleTimeoutSeconds;
|
||||
_uploadIdleTimeout = TimeSpan.FromSeconds(idleSeconds > 0 ? idleSeconds : DefaultIdleTimeoutSeconds);
|
||||
var responseSeconds = configuration.GetValue<int?>("Upload:ResponseTimeoutSeconds") ?? DefaultResponseTimeoutSeconds;
|
||||
_uploadResponseTimeout = TimeSpan.FromSeconds(responseSeconds > 0 ? responseSeconds : DefaultResponseTimeoutSeconds);
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackDto>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
long contentLength,
|
||||
string fileName,
|
||||
string contentType,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
ReleaseMedium medium = ReleaseMedium.Cut,
|
||||
IProgress<long>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 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.
|
||||
// Build the WAV part once; the two-phase send helper owns the cancellation plumbing.
|
||||
using var phase = new UploadPhase(this, ct);
|
||||
var wavContent = phase.WrapContent(wavStream, contentLength, contentType, progress);
|
||||
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
var wavContent = new StreamContent(wavStream);
|
||||
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
|
||||
multipart.Add(wavContent, "audioFile", fileName);
|
||||
multipart.Add(new StringContent(trackName), "trackName");
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(description)) multipart.Add(new StringContent(description), "description");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
|
||||
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
|
||||
@@ -68,18 +92,10 @@ public class CmsTrackService : ICmsTrackService
|
||||
// for an unrecognised value). Authoritative only when this upload creates the release.
|
||||
multipart.Add(new StringContent(medium.ToString()), "medium");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
|
||||
if (send.Response is not { } response)
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for upload of {TrackName}", trackName);
|
||||
return ResultContainer<TrackDto>.CreateFailResult("Content API is unreachable.");
|
||||
return ResultContainer<TrackDto>.CreateFailResult(send.FailureMessage!);
|
||||
}
|
||||
|
||||
using (response)
|
||||
@@ -123,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)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -371,7 +553,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
|
||||
public async Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
@@ -385,6 +567,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
artist,
|
||||
album,
|
||||
genre,
|
||||
description,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
@@ -498,6 +681,39 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform/high-res", null, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for high-res waveform generation of {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Track audio not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API high-res waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to generate high-res waveform datum.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -542,50 +758,6 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/genres", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for genre summaries");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
|
||||
}
|
||||
|
||||
List<GenreSummaryDto>? genres;
|
||||
try
|
||||
{
|
||||
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (genres is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null genre summaries list");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
||||
|
||||
@@ -21,21 +21,28 @@ public interface ICmsTrackService
|
||||
/// <paramref name="medium"/> sets the parent release's <see cref="ReleaseMedium"/> when this upload
|
||||
/// creates the release. The medium is authoritative only on creation — adding a track to an existing
|
||||
/// release never changes its medium (that is the edit path, <see cref="UpdateAsync"/>).
|
||||
/// <paramref name="contentLength"/> is the total payload size (the browser file's <c>Size</c>); it
|
||||
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
|
||||
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
|
||||
/// stalled connection aborts without a fixed total-duration cap.
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
long contentLength,
|
||||
string fileName,
|
||||
string contentType,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album,
|
||||
string? genre,
|
||||
string? description,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
ReleaseMedium medium = ReleaseMedium.Cut,
|
||||
IProgress<long>? progress = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -44,6 +51,24 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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.
|
||||
@@ -85,7 +110,7 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? album, string? genre, string? description, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
ReleaseMedium? medium = null,
|
||||
@@ -104,12 +129,17 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trigger high-res visualizer datum generation for a single track via
|
||||
/// <c>POST api/track/{entryKey}/waveform/high-res</c> (phase-12 §5). Re-runnable — recomputes on each
|
||||
/// call. Drives the per-row generate action and the batch backfill. Maps a 404 to a "Track audio not
|
||||
/// found." failure.
|
||||
/// </summary>
|
||||
Task<Result> GenerateHighResWaveformAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="HttpContent"/> that streams a source stream to the wire while reporting cumulative
|
||||
/// bytes written after each chunk. This is the single source of truth for both the upload progress
|
||||
/// meter and the idle/heartbeat timeout: every reported tick advances the UI <em>and</em> resets the
|
||||
/// idle deadline, so one mechanism feeds both concerns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wrap the audio payload (not the whole multipart container) so <see cref="TryComputeLength"/>
|
||||
/// returns the file length and the reported byte counts map directly onto "bytes of this file".
|
||||
/// </remarks>
|
||||
public sealed class ProgressStreamContent : HttpContent
|
||||
{
|
||||
// 80 KB: large enough to keep the socket fed on a healthy link, small enough that a stalled
|
||||
// connection trips the idle window without a multi-MB write swallowing the whole heartbeat budget.
|
||||
private const int CopyBufferSize = 81_920;
|
||||
|
||||
private readonly Stream _source;
|
||||
private readonly long _length;
|
||||
private readonly Action<long> _onBytesWritten;
|
||||
|
||||
/// <param name="source">The payload stream. Read once, sequentially — not seekable-rewound.</param>
|
||||
/// <param name="length">Total bytes the source will yield; sets Content-Length and the meter denominator.</param>
|
||||
/// <param name="onBytesWritten">Invoked after each chunk with the cumulative bytes written so far.</param>
|
||||
public ProgressStreamContent(Stream source, long length, Action<long> onBytesWritten)
|
||||
{
|
||||
_source = source;
|
||||
_length = length;
|
||||
_onBytesWritten = onBytesWritten;
|
||||
}
|
||||
|
||||
// Token-aware overload (.NET 5+): HttpClient calls this on the send path and passes the request's
|
||||
// CancellationToken, so the idle-heartbeat CTS aborts an in-flight read/write promptly — not just
|
||||
// between chunks. The parameterless base override delegates here with CancellationToken.None.
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken)
|
||||
=> CopyAsync(stream, cancellationToken);
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
|
||||
=> CopyAsync(stream, CancellationToken.None);
|
||||
|
||||
private async Task CopyAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[CopyBufferSize];
|
||||
long written = 0;
|
||||
int read;
|
||||
while ((read = await _source.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await stream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
written += read;
|
||||
// Report after the bytes are on the wire — a tick means real forward progress, which is
|
||||
// exactly the signal the idle heartbeat must reset on.
|
||||
_onBytesWritten(written);
|
||||
}
|
||||
}
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = _length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,9 @@
|
||||
"AllowedHosts": "*",
|
||||
"ForwardedHeaders": {
|
||||
"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 |
@@ -15,7 +15,8 @@ DeepDrftModels/
|
||||
├── Entities/
|
||||
│ └── TrackEntity.cs # Database entity for tracks
|
||||
├── 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/
|
||||
│ ├── PagingParameters.cs # Pagination configuration (base + generic)
|
||||
│ └── 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 DateOnly? ReleaseDate { get; set; } // Optional release date
|
||||
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`.
|
||||
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
### PagingParameters (base)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -9,9 +9,14 @@ namespace DeepDrftModels.DTOs;
|
||||
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
|
||||
public class ReleaseDto : BaseModel
|
||||
{
|
||||
// The release's public handle — the GUID string the public site/API address it by (Phase 11 §3e).
|
||||
// Mirrors TrackDto's EntryKey. No `required` (BaseModel/Manager<> need new()); TrackConverter
|
||||
// assigns it on every round-trip, so an empty default is never observable.
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Genre { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseMedium Medium { get; set; } = ReleaseMedium.Cut;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class TrackDto : BaseModel
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string? OriginalFileName { get; set; }
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
public double? DurationSeconds { get; set; }
|
||||
public long? ReleaseId { get; set; }
|
||||
public ReleaseDto? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
|
||||
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
|
||||
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
|
||||
/// vault key used to trigger generation for a missing profile.
|
||||
/// Per-track waveform datum status for the CMS PreProcessing panel. Tells admins which tracks already
|
||||
/// carry each stored datum and which need backfilling. <see cref="HasProfile"/> is the 512-bucket
|
||||
/// player-bar profile; <see cref="HasHighRes"/> is the duration-derived high-res visualizer datum
|
||||
/// (phase-12 §5 — every track now carries one). <see cref="EntryKey"/> is the vault key used to trigger
|
||||
/// generation for either missing datum.
|
||||
/// </summary>
|
||||
public class WaveformStatusDto
|
||||
{
|
||||
@@ -12,4 +13,5 @@ public class WaveformStatusDto
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public bool HasProfile { get; set; }
|
||||
public bool HasHighRes { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 (< 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 (> 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -12,9 +12,19 @@ namespace DeepDrftModels.Entities;
|
||||
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
public class ReleaseEntity : BaseEntity, IEntity
|
||||
{
|
||||
// App-minted GUID-string public handle, mirroring TrackEntity.EntryKey exactly: required string,
|
||||
// entry_key column, unique index, born app-side as Guid.NewGuid().ToString() at release creation
|
||||
// (the FindOrCreateRelease path). Fronts the int Id (the DB-only PK, unused by the app) so the
|
||||
// public site/API address releases by an opaque key, never the transparent sequential id
|
||||
// (Phase 11 §3e). Unlike a track's EntryKey it has no vault job — it is purely an identifier.
|
||||
public required string EntryKey { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
// Free-text prose blurb describing the release. Uniform across media (Cut/Session/Mix), so it
|
||||
// lives on the base table alongside Genre rather than in a per-medium satellite. Plain text,
|
||||
// max 4000 (configured in ReleaseConfiguration); nullable so existing rows migrate as NULL.
|
||||
public string? Description { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -15,6 +15,10 @@ public class TrackEntity : BaseEntity, IEntity
|
||||
public required string TrackName { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
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 ReleaseEntity? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 < 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 > 80% of duration — effectively a finished listen.</summary>
|
||||
Complete
|
||||
}
|
||||
@@ -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><iframe></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
|
||||
}
|
||||
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance). **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).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
@@ -19,27 +19,55 @@ 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]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
|
||||
- `AudioPlayerBar/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/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 (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). 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.
|
||||
- `ReleaseHeroOverlay.razor`: Shared presentational overlay shell consumed by both `SessionDetail` and `MixDetail`. Renders a background-image hero region with genre/date/share overlaid at the top and title/artist/play at the bottom. Parameters: `HeroImageKey`, `PlaceholderIcon`, `CoverThumbKey` (optional cover thumb in bottom row), `Title`, `Artist`, `Genre`, `ReleaseDate`, `ShareContent` (slot), `PlayContent` (slot), `Class` (per-page aspect/sizing override). Owns no player logic or data fetch; each consuming page passes its own play and share slots. Overlay shell is plain `<div>`s; background-image surface is a `<div class="release-hero-img">` (no `MudPaper`).
|
||||
- `ReleaseDescription.razor`: Shared presentational blurb block for release detail pages. Renders a header row (mono uppercase eyebrow label "Notes" in `.deepdrft-release-description-label` + thin horizontal divider in `.deepdrft-release-description-rule`, inside `.deepdrft-release-description-header`) above the release's short `Description` paragraph (`.deepdrft-release-description-text`, display-serif weight 300), all wrapped in `.deepdrft-release-description`, placed just below the hero/header. The header row mirrors the home page's `.section-label`/`.divider-line` motif. Purely presentational — owns no data fetch or player wiring. A null/whitespace `Description` renders nothing at all (no block, no divider, no whitespace artifact; the guard lives here so every consumer gets it for free). Parameter: `Description` (`string?`).
|
||||
- `WaveformVisualizer.razor`: The single WebGL2 lava-lamp visualizer engine. Hosts the waveform of whatever track is currently playing/selected. Three hosting modes: mode A (Mix detail — full-bleed centerpiece), mode B (Cut/Session detail — ambient layer behind hero+content via `ReleaseDetailScaffold`'s `Ambient` slot), mode C (NowPlaying hero panel — full-bleed background for the home hero's right side, mounted by `NowPlaying.razor` inside `.np-visualizer-bg`). `[Parameter] bool Fill` switches from fixed-viewport positioning to container-relative sizing (CSS-only; the renderer is identical in both modes). The bridge resolves the current track's `EntryKey` and re-fetches the high-res datum on track change. Subscribes to `WaveformVisualizerControlState.Changed` and pushes each updated dial to the WebGL module via JS interop. Follows the live playing track (keys on host `TrackId` match OR shared host `ReleaseEntryKey`).
|
||||
- `WaveformVisualizerControls.razor`: The waveform visualizer control panel (content hosted by `WaveformVisualizerControlPopover`). Phase 15 re-layout: a deterministic **three-row sectioned layout** encoding the visualizer's two subsystems. Row 1 (MODE, always visible): two iconographic lamp toggles (lava on/off, waveform on/off) left-aligned + collisions knob (conditional — only when both subsystems on) + color knob pinned far-right. Row 2 (LAVA, visible only when `LavaEnabled`): "LAVA:" section label + Gravity / Heat / FluidAmount / FluidViscosity knobs. Row 3 (WAVE, visible only when `WaveformEnabled`): "WAVE:" section label + scroll-speed `MudSlider` (not a knob) + width knob pinned far-right. Total: two lamp toggles, seven `RadialKnob`s, one `MudSlider`. Colour principle: lamp toggles / knob arcs / slider are green (`Color.Primary` — interactive); section labels / knob caption icons are light (static). Each control has a playful `MudTooltip`. `[Parameter] bool PanelChrome` scopes panel chrome (NowPlayingCard look — square corners, lighter-navy, thin border) to the popover mount; chrome classes live in the global `deepdrft-styles.css` (CSS isolation cannot reach portaled overlay content). `[Parameter] bool Visible` gates the rows via `@if` while the container holds reserved min-height. Owns no JS interop: mutates the injected `WaveformVisualizerControlState` and raises `Changed`. No control is a seek surface (read-only contract).
|
||||
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
|
||||
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
|
||||
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
|
||||
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
- `StreamingAudioPlayerService`: Production implementation. Chunked stream from `TrackMediaClient`, adaptive 16–64 KB buffer, early-playback, **seek-beyond-buffer** via offset request to the content API.
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
|
||||
- `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).
|
||||
- `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.
|
||||
- `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/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.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
|
||||
- `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).
|
||||
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
|
||||
- `_Imports.razor`: Global using statements and component imports.
|
||||
|
||||
@@ -95,7 +123,7 @@ New modules in `DeepDrftPublic/Interop/audio/`:
|
||||
- `DarkModeServiceBase`: Holds the cookie name constant (`"darkMode"`).
|
||||
- `DarkModeCookieService`: Reads/writes the cookie via JS (`document.cookie` interop). Calls `DarkModeSettings.IsDarkMode = value` when the cookie changes or user toggles the button.
|
||||
- Server-side `DarkModeService` (in `DeepDrftPublic`, **not here**): Reads the cookie during prerender, seeds the `DarkModeSettings` instance, rounds it through `PersistentComponentState` to the client.
|
||||
- `MainLayout.razor`: Wraps entire layout in `CascadingValue` of `DarkModeSettings`, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from `DDIcons.cs`) calls `DarkModeCookieService.ToggleDarkModeAsync()`.
|
||||
- `MainLayout.razor`: Wraps entire layout in `CascadingValue` of `DarkModeSettings`, so all children see the current dark-mode state. The dark-mode toggle button (hand-rolled lit/unlit gas-lamp icon from `DeepDrftShared.Client/Common/DDIcons.cs`) calls `DarkModeCookieService.ToggleDarkModeAsync()`.
|
||||
|
||||
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
|
||||
|
||||
@@ -110,7 +138,7 @@ Component state lives in ViewModels (registered scoped in DI). Components render
|
||||
|
||||
- Bespoke `PaletteLight` / `PaletteDark` defined inline in `MainLayout.razor` (MudBlazor theme objects).
|
||||
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
|
||||
- Custom SVG icons: `Common/DDIcons.cs` (hand-rolled gas-lamp, etc.).
|
||||
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
|
||||
|
||||
## Development commands
|
||||
|
||||
@@ -140,5 +168,6 @@ dotnet test DeepDrftTests/
|
||||
- **Result types**: Clients return `ApiResult<T>` from NetBlocks. UI checks `Success` before using `Value`.
|
||||
- **Async/await**: All operations are async.
|
||||
- **Stream consumption**: `TrackMediaClient.GetAudioStreamAsync` returns a `Stream` (not fully buffered). `StreamingAudioPlayerService` reads it in chunks to avoid memory pressure on large files.
|
||||
- **Detail pages under InteractiveAuto must load in `OnParametersSetAsync`, not `OnInitializedAsync`**: Blazor reuses a scoped component instance across same-template navigations (e.g. `/mixes/5` → `/mixes/8`), firing `OnParametersSet`/`OnParametersSetAsync` rather than re-running `OnInitialized`. If load logic is in `OnInitialized` only, the ViewModel retains the prior track and Play will stream the wrong item. Capture the route parameter (id/key) synchronously at the top of `OnParametersSetAsync` before any await — after an await the route state may have advanced. Guard `PersistentComponentState` restores on id/key equality to prevent cross-item bleed when the prerender and WASM-boot passes disagree on which item is current.
|
||||
|
||||
When working with this project, maintain the separation between presentation (Razor components) and logic (ViewModels/Clients), follow the established audio player architecture, and respect the dark-mode round-trip (cookie → DarkModeSettings → PersistentComponentState → client).
|
||||
|
||||
@@ -69,9 +69,9 @@ public class ReleaseClient
|
||||
: ApiResult<PagedResult<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<ReleaseDto>> GetById(long id)
|
||||
public async Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/release/{id}");
|
||||
var response = await _http.GetAsync($"api/release/{Uri.EscapeDataString(entryKey)}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<ReleaseDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
@@ -83,27 +83,4 @@ public class ReleaseClient
|
||||
? ApiResult<ReleaseDto>.CreatePassResult(release)
|
||||
: ApiResult<ReleaseDto>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the high-res waveform datum for a Mix release. A 404 means no datum is stored
|
||||
/// (not yet generated, or not a Mix) — a valid state, so it returns a pass result with a
|
||||
/// null value. Any other non-success status is a genuine failure.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<WaveformProfileDto?>> GetMixWaveform(long id)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/release/{id}/mix/waveform");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, JsonOptions);
|
||||
|
||||
return profile is not null
|
||||
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
|
||||
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,33 @@ public class TrackClient
|
||||
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the per-track high-res waveform datum, addressed by the track's EntryKey (phase-12 §5b).
|
||||
/// A 404 means no high-res datum is stored (a track not yet backfilled) — a valid state, so it
|
||||
/// returns a pass result with a null value and the visualizer blanks gracefully. Any other
|
||||
/// non-success status is a genuine failure.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/track/{Uri.EscapeDataString(trackEntryKey)}/waveform/high-res");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return ApiResult<WaveformProfileDto?>.CreatePassResult(null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<WaveformProfileDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var profile = JsonSerializer.Deserialize<WaveformProfileDto>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return profile is not null
|
||||
? ApiResult<WaveformProfileDto?>.CreatePassResult(profile)
|
||||
: ApiResult<WaveformProfileDto?>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user