Compare commits
314 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ebc80a2a | |||
| 68bf328e7c | |||
| b5bd1c977b | |||
| 4b26e0a969 | |||
| c6078a3e71 | |||
| 0874042040 | |||
| 6d3b9cd4d3 | |||
| 9792d4346e | |||
| fc20a5d3d2 | |||
| f02974b3c2 | |||
| a6e565e445 | |||
| 38e345ccf7 | |||
| fd8c0e389f | |||
| b359786e69 | |||
| bef3f590ca | |||
| 407ed90341 | |||
| 92a3bea129 | |||
| 6480953189 | |||
| b22c3f96d7 | |||
| 62620bc0d4 | |||
| 55b26b2e41 | |||
| 508a522a8d | |||
| cf557e16aa | |||
| a2f9742f8a | |||
| a29b961c27 | |||
| e077b8ec7b | |||
| 612b21b1e7 | |||
| 70d4a87cd5 | |||
| ae531116b7 | |||
| 63bdc5ee93 | |||
| f767d288c5 | |||
| 9d7f2ff003 | |||
| c59f59c3fe | |||
| 91566692f6 | |||
| 16f356a760 | |||
| 8983592e56 | |||
| 92ddc5bb3e | |||
| 76e5080278 | |||
| 675710d086 | |||
| c46c3a2f9c | |||
| 49e99ff986 | |||
| 5a345cabea | |||
| 25ade16b07 | |||
| 5d9ba1c953 | |||
| ab418bf840 | |||
| d3f1d6a8a0 | |||
| 4d9505c341 | |||
| 0439d3da4f | |||
| 98142754fa | |||
| 3da12067f6 | |||
| 86e1243eba | |||
| b6b212e429 | |||
| 879c30a5e5 | |||
| a2771c71aa | |||
| 81b8796ba5 | |||
| 489215e415 | |||
| b7b5933b25 | |||
| c4930e80ba | |||
| b04081b960 | |||
| bd6bd4d827 | |||
| c835a54652 | |||
| 909d259df9 | |||
| f10e20a0e2 | |||
| 009f565b73 | |||
| 4a46ec36b3 | |||
| 0b0bcb3dee | |||
| 34e7f2f8ed | |||
| 3bb8104967 | |||
| a82bd875d9 | |||
| 72171c9374 | |||
| 480c961a09 | |||
| 754dc311a6 | |||
| d47a5e00af | |||
| 77dee5eac5 | |||
| f8186fb7c7 | |||
| 092ac0b5f2 | |||
| 3953229ae4 | |||
| 8d80d43a47 | |||
| eddbb00cd9 | |||
| aa1f7d50f1 | |||
| b4cda76114 | |||
| 38529a962a | |||
| d2a9475ba2 | |||
| e84823be39 | |||
| 6c602170a9 | |||
| 88ac5b2c88 | |||
| 0f5eaa42b5 | |||
| f0185587f7 | |||
| 0a5ddfdad8 | |||
| 8b94a5fdf7 | |||
| fb27918ed6 | |||
| 691d904273 | |||
| ded5a3e5eb | |||
| f25d0f624f | |||
| 43f54cb950 | |||
| f40940b957 | |||
| 10256677ac | |||
| 6fe7663667 | |||
| 5cae83b9ed | |||
| d9b92e0703 | |||
| 0fd1977353 | |||
| 1071ba7374 | |||
| 79a015f60a | |||
| 0bd7e6904d | |||
| f602eb9772 | |||
| b372bee365 | |||
| fad3635fa1 | |||
| 561f4a500a | |||
| 9be35e5a58 | |||
| aaa9f732ae | |||
| 5c3c3c3d0c | |||
| 760e9a1982 | |||
| 5b3bbc7b47 | |||
| f40786171d | |||
| cef1e6bc69 | |||
| 5258729c86 | |||
| 8679a9f619 | |||
| ae22153edb | |||
| e3df6dd93e | |||
| 6151e6024c | |||
| 505ac0c47b | |||
| 6cacf51318 | |||
| 87971dbd6f | |||
| 881d3d49cd | |||
| 561cd45237 | |||
| 4e6e3c9eab | |||
| 4ab48ce527 | |||
| 58725c4646 | |||
| 9cbc09edf7 | |||
| 149127c920 | |||
| ad1c85f3ee | |||
| 095b49701f | |||
| 0392ef6954 | |||
| c086d03776 | |||
| b9969640e5 | |||
| a2814fc939 | |||
| 5b50879476 | |||
| 16f4f894f9 | |||
| 2bac1520db | |||
| 6ce7c580a0 | |||
| 1c942ffb2b | |||
| b88af29731 | |||
| 21e1a33ccf | |||
| 2db9a6251a | |||
| 00a3cc8034 | |||
| 6705c52b69 | |||
| 4e6cda939d | |||
| 1bd27f2160 | |||
| 8fbabcdbc5 | |||
| 1fdffb1e50 | |||
| 2eebc04733 | |||
| 7eae599490 | |||
| 9169493d41 | |||
| f1da2382d2 | |||
| 165d935ae7 | |||
| cef4d243f3 | |||
| d07ebc9e66 | |||
| 317e9f84b8 | |||
| c57e61f7f9 | |||
| 2e165d0aef | |||
| b7b539743b | |||
| 0e5cf7e79d | |||
| 3f02686012 | |||
| 9015411f12 | |||
| 0d4ef369b9 | |||
| 4b1a68aa29 | |||
| ea535e0c7e | |||
| ceb0984262 | |||
| 94a2789127 | |||
| 2b4cdeaf72 | |||
| 7cd85f0bb1 | |||
| 465cb1ff6c | |||
| 40e001cc7a | |||
| a6eba5d8c3 | |||
| c766cdf5b8 | |||
| 905d7fa409 | |||
| c4dc382bd7 | |||
| fa28bfb5cc | |||
| 5703ac2752 | |||
| 10cb96ef7c | |||
| f6616ed109 | |||
| 6ef88bef38 | |||
| 7bd9a434ca | |||
| 627d5623f0 | |||
| 1e9313a5d7 | |||
| 5bc1b63b61 | |||
| 9ead3bf2a7 | |||
| eecab12f48 | |||
| 858110306c | |||
| 4e6ec75000 | |||
| 8e4d783ec2 | |||
| daa334a947 | |||
| bd15b66aee | |||
| 4072197313 | |||
| 22452815c6 | |||
| 8ba3a10e15 | |||
| ba31e124f2 | |||
| 86d70c1af6 | |||
| e04f780014 | |||
| 80a79c1232 | |||
| 75766154bb | |||
| cb9c5f9b3c | |||
| 5d3ea49de8 | |||
| a2b8b12bf0 | |||
| fcaf8f0bf6 | |||
| 3de88c786a | |||
| 5cdd69d7d9 | |||
| 6dfb3a2f23 | |||
| 54939721e4 | |||
| ec88759b55 | |||
| 8b3e7e0620 | |||
| 18b5fa9401 | |||
| c4e7b49776 | |||
| 13adb144a6 | |||
| 84a302ce24 | |||
| 47d0475d3f | |||
| 4341d97f12 | |||
| bd110c07da | |||
| d1cb85b840 | |||
| 07ba9946ce | |||
| 4b5de088ab | |||
| 9ce2631bf4 | |||
| 475f93c8a3 | |||
| a4b098b8ea | |||
| 7dfdad2666 | |||
| b1d58c1327 | |||
| 6b18d7cc1e | |||
| 93d9b47a67 | |||
| 0dd33a5dfc | |||
| 3e4ddbb2a6 | |||
| 1bb6e29e47 | |||
| c83b132522 | |||
| d96c41eafb | |||
| 9110b4b764 | |||
| 526e607f33 | |||
| 7d3da58573 | |||
| e3fe401abf | |||
| 1d97729e57 | |||
| 766e98fd2b | |||
| d055c2a548 | |||
| 75bf93c2bb | |||
| b746645f97 | |||
| ab9db6d0ec | |||
| 3dc9fc2446 | |||
| 59dbfb8aab | |||
| 76e16fe32e | |||
| 97c8439ed7 | |||
| cabc8654d1 | |||
| f468fafaba | |||
| af6ed6130f | |||
| 6e25ad3085 | |||
| 75db127708 | |||
| 84307dabde | |||
| 1b493434d6 | |||
| 2ee0667aa2 | |||
| 9c916245c1 | |||
| 8de7342352 | |||
| acd76e0601 | |||
| 7c89220667 | |||
| 9cfcd5f67a | |||
| 9538310c43 | |||
| b3473aa37e | |||
| de4583b759 | |||
| 9d39843982 | |||
| edf45bb8de | |||
| 9854d51940 | |||
| 92f860897b | |||
| cc1fa60a4d | |||
| fa57861dbf | |||
| 7c401d75b5 | |||
| 3c17260f32 | |||
| 61c5bee5d7 | |||
| eed99df0dd | |||
| 1986aed902 | |||
| c10d315a7b | |||
| b9b2c131a8 | |||
| 231ed399a3 | |||
| d9664988ad | |||
| b22b57069d | |||
| a86ccae432 | |||
| 87f722fa58 | |||
| 31d2c2ee7e | |||
| 78c6803e6b | |||
| 8178174275 | |||
| ffb71b6d71 | |||
| cbc43300b2 | |||
| 190d8d044f | |||
| 4887454911 | |||
| 0c5ebae9c9 | |||
| 91214336c5 | |||
| 4616fbf0e1 | |||
| 72e9f71fbc | |||
| b6572bead0 | |||
| f07ab4b235 | |||
| 73e0eea328 | |||
| dbf02a9426 | |||
| b24c6ff78e | |||
| de0c01ef4d | |||
| 8420ab8d37 | |||
| a57e0f71c4 | |||
| 7622e94ba2 | |||
| 034e9d5633 | |||
| db8a44fc79 | |||
| 6e274b7395 | |||
| 21b7661ca8 | |||
| 79591fe4e4 | |||
| c69c25c6dc | |||
| 4171b493fd | |||
| fe8ddff41c | |||
| 58a94fe315 | |||
| 757c1d5c85 | |||
| 194a76ce4c | |||
| a34e083c2e | |||
| 52d6afa335 |
@@ -9,7 +9,6 @@ on:
|
||||
- 'DeepDrftContent/**'
|
||||
- 'DeepDrftModels/**'
|
||||
- '.gitea/workflows/deploy-api.yml'
|
||||
- 'deploy/systemd/deepdrftapi.service'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -110,7 +109,6 @@ jobs:
|
||||
rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \
|
||||
staging/deepdrft-api.tar.gz \
|
||||
staging/deepdrft-migrations-bundle \
|
||||
deploy/systemd/deepdrftapi.service \
|
||||
deepdrft@$DEPLOY_HOST:
|
||||
|
||||
- name: Trigger deploy on host
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install wasm-tools workload
|
||||
run: dotnet workload install wasm-tools
|
||||
|
||||
|
||||
+4
-1
@@ -311,4 +311,7 @@ __pycache__/
|
||||
Database/Vaults/*
|
||||
|
||||
# TypeScript output
|
||||
**/wwwroot/js/*
|
||||
**/wwwroot/js/*
|
||||
# ...except hand-authored client JS modules (not TS compile output).
|
||||
!DeepDrftPublic.Client/wwwroot/js/
|
||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||
@@ -14,7 +14,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
|
||||
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
|
||||
|
||||
@@ -70,7 +70,7 @@ The player is not fetch-then-play:
|
||||
2. `StreamingAudioPlayerService` reads in adaptive 16–64 KB chunks, pushes each via `AudioInteropService.processStreamingChunk`.
|
||||
3. TypeScript `StreamDecoder` parses WAV header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
|
||||
4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}?offset={byteOffset}`. Server's `WavOffsetService` block-aligns offset, synthesises a fresh 44-byte WAV header, streams `[new header][data from offset]`. Player tears down and re-initialises decoder for the new stream.
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}` with `Range: bytes={byteOffset}-`. Server streams raw bytes from that file-absolute offset with a `206 Partial Content` response. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
|
||||
|
||||
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
|
||||
|
||||
|
||||
+1211
File diff suppressed because it is too large
Load Diff
+74
-50
@@ -2,61 +2,77 @@
|
||||
|
||||
Living orientation doc for what this repo is, how it is currently shaped, and where it appears headed. Sits alongside the root `CLAUDE.md` (operational guidance) — this file is the product/architecture view.
|
||||
|
||||
> **Drift notice.** The root `CLAUDE.md` and every folder-level `CLAUDE.md` currently in the tree describe the project as `.NET 9`. The most recent commit upgraded all projects to `.NET 10` (every `.csproj` now targets `net10.0`, packages pinned at `10.0.1`). Until those docs are refreshed, treat any framework-version claim in them as stale. The other staleness items are listed at the bottom of this file.
|
||||
> **Status.** The root `CLAUDE.md` is current — it reflects the post-split ten-project solution, `net10.0`, and the dual-app topology. This file (`CONTEXT.md`) was the lagging document and §2 / §4 / §7 below have been brought back into line with the root `CLAUDE.md` as of 2026-06-06. Folder-level `CLAUDE.md` files are still being swept (`DOC_PLAN.md`); treat framework-version and structural claims in any *folder* `CLAUDE.md` not yet rewritten as potentially stale until that sweep lands.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this project is
|
||||
|
||||
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftWeb.Client/Pages/Home.razor`). The product is, at minimum:
|
||||
DeepDrftHome is the home + listening surface for **DeepDrft**, a two-person electronic music collective based in Charleston, SC (per `DeepDrftPublic.Client/Pages/Home.razor`). The product is, at minimum:
|
||||
|
||||
- A public-facing site (hero, about, "experience" features).
|
||||
- A public-facing site (hero, about, "experience" features) at `DeepDrftPublic`.
|
||||
- A **track gallery** that browses a library of WAV recordings, plays them in-browser with a persistent dock-style player, and supports seek (including seek beyond what's been streamed so far).
|
||||
- An admin CLI for adding tracks (Terminal.Gui or scripted), running locally against the same dual-database substrate the site uses.
|
||||
- A browser-based **CMS** (`DeepDrftManager`) for adding, editing, and deleting tracks — gated behind AuthBlocks login and the `Admin` role. This replaced the former `DeepDrftCli` Terminal.Gui admin tool, which has been retired.
|
||||
|
||||
The interesting engineering bet is the **dual-database split**: structured track metadata in SQLite via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced across two ASP.NET Core hosts so that the browser never reaches the database directly.
|
||||
The interesting engineering bet is the **dual-database split**: structured track metadata in PostgreSQL via EF Core, and binary media + per-vault indexes in a hand-rolled `FileDatabase` that lives on disk. The split is enforced through a dedicated authority host (`DeepDrftAPI`) so that the browser never reaches the database directly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution shape (current)
|
||||
|
||||
Eight projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`.
|
||||
Ten projects in `DeepDrftHome.sln`, plus an external `NetBlocks` referenced from `C:\lib\NetBlocks\`. The solution is split into **two independent Blazor applications** — the public site (`DeepDrftPublic`) and the CMS (`DeepDrftManager`) — both fronting a single dual-database authority host (`DeepDrftAPI`).
|
||||
|
||||
```
|
||||
DeepDrftWeb ASP.NET Core host. Blazor Web App (Server + WASM render modes).
|
||||
Owns the SQL-backed API (api/track/page), MudBlazor theme/host,
|
||||
TypeScript→JS audio interop sources under Interop/.
|
||||
DeepDrftWeb.Client Blazor WebAssembly assembly. All interactive UI lives here —
|
||||
pages, controls, player services, dark-mode/theme plumbing,
|
||||
HTTP clients for both backends.
|
||||
DeepDrftWeb.Services Class library. EF Core: DeepDrftContext, TrackConfiguration,
|
||||
Migrations, TrackRepository, TrackService. Sharable between
|
||||
the web host and the CLI (avoids duplicating data-access).
|
||||
── Public application ──────────────────────────────────────────────────────
|
||||
DeepDrftPublic ASP.NET Core host. Blazor Web App (Server + WASM render
|
||||
modes). Owns the browser-facing proxy controller for
|
||||
api/track/* (metadata listing + audio streaming),
|
||||
MudBlazor theme prerender, and TypeScript→JS audio interop
|
||||
sources under Interop/. The public listening surface.
|
||||
DeepDrftPublic.Client Blazor WebAssembly assembly. All interactive public UI —
|
||||
pages, the player stack, dark-mode plumbing, HTTP clients
|
||||
for the backend. Consumed by DeepDrftPublic.
|
||||
|
||||
DeepDrftContent ASP.NET Core host. Binary content API (api/track/{id}).
|
||||
ApiKey middleware, CORS, ForwardedHeaders. Returns audio bytes
|
||||
(with optional byte offset) and accepts PUT of AudioBinaryDto.
|
||||
DeepDrftContent.Services Class library. The FileDatabase implementation in full
|
||||
── CMS application ─────────────────────────────────────────────────────────
|
||||
DeepDrftManager ASP.NET Core host. Blazor Web App (InteractiveServer).
|
||||
Hosts all CMS Razor components/pages (Components/Pages/Cms/,
|
||||
Components/Pages/Tracks/, Components/Layout/CmsLayout.razor,
|
||||
Components/Shared/ — inlined from the former DeepDrftCms RCL).
|
||||
Gated by AuthBlocks login + hierarchical Admin role. All track
|
||||
operations proxy via ICmsTrackService / CmsTrackService.
|
||||
|
||||
── Dual-database authority ─────────────────────────────────────────────────
|
||||
DeepDrftAPI ASP.NET Core host. The single authority over both databases
|
||||
(SQL metadata + FileDatabase binary). AuthBlocks API host
|
||||
(registration, migration/seed, JWT endpoints). Seven track
|
||||
endpoints (stream, vault write, upload, delete, paged list,
|
||||
single metadata read, metadata update).
|
||||
DeepDrftData Class library. EF Core domain logic: DeepDrftContext,
|
||||
TrackConfiguration, Migrations, TrackRepository, TrackService,
|
||||
TrackManager. Consumed by DeepDrftAPI and tests.
|
||||
DeepDrftContent Class library. The FileDatabase implementation in full
|
||||
(Models, Services, Utils, Abstractions, Constants),
|
||||
WavOffsetService, AudioProcessor, TrackService (the content-side
|
||||
orchestrator that processes WAVs and stores them in a vault).
|
||||
WavOffsetService, AudioProcessor, content-side TrackService.
|
||||
Consumed by hosts and tests.
|
||||
|
||||
── Shared ──────────────────────────────────────────────────────────────────
|
||||
DeepDrftShared.Client Razor Class Library. Shared Blazor components consumed by
|
||||
BOTH DeepDrftPublic and DeepDrftManager (e.g. TrackCard,
|
||||
TracksGallery) for consistency across public and admin surfaces.
|
||||
DeepDrftModels Shared contracts: TrackEntity, TrackDto, PagingParameters<T>,
|
||||
PagedResult<T>. The only project all three layers reference.
|
||||
|
||||
DeepDrftCli Console app. Two modes: classic `add` / `list` / `help` and
|
||||
`gui` (Terminal.Gui). Consumes BOTH service libraries directly
|
||||
(it's a local admin tool, not a network client).
|
||||
|
||||
PagedResult<T>, plus waveform DTOs. Every project references this.
|
||||
DeepDrftTests NUnit. Covers the FileDatabase, MediaVault, IndexSystem,
|
||||
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, and
|
||||
model behaviour. References DeepDrftContent.Services.
|
||||
MediaVaultFactory, SimpleMediaTypeRegistry, utility code, model
|
||||
behaviour, and the waveform loudness algorithm. References
|
||||
DeepDrftContent.
|
||||
|
||||
NetBlocks (external) Result patterns: Result, ResultContainer<T>, ApiResult<T>,
|
||||
ApiResultDto<T>. Referenced via absolute path.
|
||||
```
|
||||
|
||||
Two stray .sln files (`WebAPI.sln`, `WebUI.sln`, `CLI.sln`) exist at the root alongside `DeepDrftHome.sln`. `DeepDrftHome.sln` is the canonical solution; the others appear to be subsets.
|
||||
**Naming history (for readers of older docs/commits):** `DeepDrftWeb` → `DeepDrftPublic`, `DeepDrftWeb.Client` → `DeepDrftPublic.Client`, `DeepDrftWeb.Services` → `DeepDrftData`, `DeepDrftContent.Services` → `DeepDrftContent` (the host that previously owned the binary API is gone; its proxy duties moved into `DeepDrftPublic`, its authority duties into `DeepDrftAPI`). `DeepDrftCli` and the `DeepDrftCms` RCL have both been removed — the CLI retired in favour of the CMS, and the CMS RCL was inlined into `DeepDrftManager`.
|
||||
|
||||
**Subdomain topology (deployment):** `deepdrft.com` (public) and `manage.deepdrft.com` (CMS), behind nginx. CD infrastructure (Gitea workflows + installer scripts + systemd/nginx templates) has landed — see `COMPLETED.md` "Deployment Infrastructure."
|
||||
|
||||
---
|
||||
|
||||
@@ -147,17 +163,19 @@ In dev, the host serves the original `.ts` sources at `/Interop/...` for source-
|
||||
|
||||
Recent commits (newest first):
|
||||
|
||||
- `style simplification and publish upgrades for dotnet 10`
|
||||
- `Styles & Home Page Content Cleanup Mobile Menu System & Dark Mode Cookie Theme Draft`
|
||||
- `Theming Draft 2`
|
||||
- `2026 Deep DRFT Theme Draft 1 WIP`
|
||||
- `Spectrum Visualizer for player & Layout`
|
||||
- `docs: update CLAUDE.md files to reflect Range header seek, remove WavOffsetService references`
|
||||
- `chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1)`
|
||||
- `feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client`
|
||||
- `refactor: extract StreamNowButton component shared by hero and nav menu`
|
||||
- (earlier: WaveformSeeker improvements, play-state icon normalization, CMS build-out, the two-app split, deployment infrastructure)
|
||||
|
||||
Three observations:
|
||||
Observations:
|
||||
|
||||
1. **The current arc is presentation, not capability.** The last five commits are framework upgrade, theming, content/layout cleanup, mobile menu, dark-mode persistence, and the spectrum visualiser. The playback substrate, streaming, and seek-beyond-buffer machinery landed earlier and is stable enough to support cosmetic iteration on top.
|
||||
2. **The "Track Gallery" is the only real page.** `/tracks` is the working surface; `/` is marketing copy. Nav (in `Pages.cs`) defines only `Home` + `Track Gallery`.
|
||||
3. **Content surface is narrow on purpose.** The DeepDrftContent API exposes exactly two routes: `GET api/track/{id}` (with optional `offset`) and `PUT api/track/{id}` (ApiKey). There is no listing endpoint there; listing lives on DeepDrftWeb because listings are SQL queries.
|
||||
1. **The big structural moves have landed.** Since the last revision of this doc, three large initiatives shipped: the **two-app split** (public/CMS separation with `DeepDrftAPI` as the dual-database authority), the **browser CMS** replacing the CLI (auth via AuthBlocks, stealth-routed `/cms/*`, full add/list/edit/delete parity), and **CD infrastructure** (Gitea workflows + host installer + systemd/nginx templates). The substrate is no longer the frontier — the product and presentation layers are.
|
||||
2. **Phase 4 streaming work is complete.** HTTP Range header seek (`Range: bytes=X-`) is now the sole seek mechanism; `WavOffsetService` and the `?offset=` path have been removed (Phase 4.1, merged 2026-06-09). `StreamDecoder.reinitializeForRangeContinuation` handles Range continuations by retaining the parsed WAV header. The streaming substrate is solid.
|
||||
3. **The embeddable iframe player has landed** (commit `c83b132`, 2026-06-07). The presentation layer now includes a chrome-free single-track embed surface for off-site use, completing the Phase 4 feature set.
|
||||
4. **The "Track Gallery" is still the only real public content page.** `/tracks` is the working listening surface; `/` is the (reskinned) marketing home. Nav (in `Layout/Pages.cs`) is still essentially `Home` + `Track Gallery`. The CMS adds admin surfaces under `/cms` but those are not public.
|
||||
5. **The metadata/streaming surface is consolidated on `DeepDrftAPI`.** It exposes seven track endpoints (stream, vault write, upload, delete, paged list, single-metadata read, metadata update) plus waveform endpoints. `DeepDrftPublic` is a thin browser-facing proxy in front of it; the browser never reaches `DeepDrftAPI` or the databases directly.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +185,7 @@ Captured here so the next round of planning has a starting point — none of thi
|
||||
|
||||
- **More vault types in active use.** `MediaVaultType.Image` exists end-to-end (tests cover it) but the production surface only registers a `tracks` vault of type `Audio`. The path to releases/albums probably runs through images first (cover art via `ImagePath`, which is currently a free-form URL string).
|
||||
- **More than one collection view.** The `TrackCard` already conditionally renders `ImagePath`, `Album`, `Genre`, `ReleaseDate` — the data shape supports album-grouped or genre-filtered views without schema work.
|
||||
- **Upload from the web side, not just the CLI.** The CLI is currently the only producer of tracks. A web-side upload would re-use `DeepDrftContent.Services.TrackService.AddTrackFromWavAsync` and pair it with a `TrackService.Create` on the SQL side. The `[ApiKeyAuthorize]` middleware on `PUT api/track/{id}` is already in place.
|
||||
- **Web upload — landed.** *(Historical note: this was a "likely direction" when the CLI was the only producer. It has since shipped.)* The CMS (`DeepDrftManager`) now produces tracks via `POST api/track/upload` on `DeepDrftAPI`, proxied through the auth-gated CMS surface. The CLI has been retired. The dual-write rollback gap (`PLAN.md §4.3`) still stands.
|
||||
- **Live/session content.** The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these yet; they would likely need new vault types (`MediaVaultType.Media` is the obvious home for video) and new entity tables.
|
||||
- **Non-WAV formats.** Today the producer side is WAV-only (`AudioProcessor.ProcessWavFileAsync` validates RIFF/WAVE/PCM). `MimeTypeExtensions` already knows mp3/flac/aac/ogg/m4a — the gap is a processor per format and a decoder strategy in the JS player (currently WAV-specific).
|
||||
- **Search / filter on the gallery.** `TracksViewModel` exposes `SortBy` / `IsDescending` but no filter. `TrackService.GetPaged` accepts only sort, not filter. Adding filter would be a natural next step on the same pagination contract.
|
||||
@@ -187,14 +205,20 @@ Captured here so the next round of planning has a starting point — none of thi
|
||||
|
||||
## 7. Staleness in existing docs (for doc-keeper to address)
|
||||
|
||||
Captured so the next sweep of folder-level `CLAUDE.md` files can correct in one pass.
|
||||
Two layers of drift remain. The root `CLAUDE.md` and this `CONTEXT.md` are current; the lag is now in **folder-level `CLAUDE.md` files** and the in-tree `FileDatabase` README. `DOC_PLAN.md` holds the per-folder rewrite briefs, but note that `DOC_PLAN.md` itself was authored against the *pre-split* project names (2026-05-16) and is partly superseded — see the warning at the end of this section.
|
||||
|
||||
- Every folder `CLAUDE.md` says ".NET 9" / "ASP.NET Core 9.0"; reality is `net10.0` across the board.
|
||||
- `DeepDrftModels/CLAUDE.md` and `DeepDrftContent.Services/FileDatabase/README.md` reference `TrackEntity.MediaPath`; the field is `EntryKey` and the column is `entry_key`.
|
||||
- `DeepDrftContent/CLAUDE.md` describes a `FileDatabase/` tree inside `DeepDrftContent/`; that tree has moved entirely to `DeepDrftContent.Services/FileDatabase/`. The DeepDrftContent host now contains only `Controllers/`, `Middleware/`, `Models/` (settings POCOs), `environment/`, `Program.cs`, `Startup.cs`.
|
||||
- `DeepDrftContent/CLAUDE.md` documents only the PUT endpoint; the production API now also has `GET api/track/{id}?offset=` (unauthenticated read, with `WavOffsetService` for offset streaming).
|
||||
- `DeepDrftWeb/CLAUDE.md` describes EF Core, repositories, services, migrations as living inside `DeepDrftWeb/Data` and `DeepDrftWeb/Services`. They have all moved to `DeepDrftWeb.Services`. The only things still in `DeepDrftWeb` are `Controllers/TrackController.cs`, `Services/DarkModeService.cs`, `Startup.cs`, `Program.cs`, `Components/`, `Interop/`, `wwwroot/`.
|
||||
- `DeepDrftWeb.Client/CLAUDE.md` lists the `Pages/` directory as containing `Counter.razor` / `Weather.razor` (demo); those are gone. The real client structure is `Pages/Home.razor` + `Pages/TracksView.razor`, plus the `Controls/AudioPlayerBar/` cluster, `Controls/AudioPlayerProvider.razor`, `Services/AudioInteropService.cs` + `AudioPlayerService.cs` + `StreamingAudioPlayerService.cs` + `IPlayerService.cs` + dark-mode services, `Common/DarkModeSettings.cs` + `Common/DDIcons.cs`, and `Layout/Pages.cs` + `Layout/DeepDrftMenu.razor`.
|
||||
- The `DeepDrftWeb.Services` and `DeepDrftContent.Services` projects have **no** `CLAUDE.md` yet — they are where most of the domain logic actually lives, so this is the biggest gap.
|
||||
- `DeepDrftCli/CLAUDE.md` references `appsettings.json`; the CLI actually loads `environment/connections.json` into `CliSettings` (with `ConnectionString` and `VaultPath`). The "Available Commands" section is otherwise current, including the `gui` Terminal.Gui mode and interactive `add`.
|
||||
- `DeepDrftContent.Services/FileDatabase/README.md` (an in-tree dev README, not a CLAUDE.md) refers to `ImageDirectoryVault`; the type is `ImageVault`. It also describes `EntryKey` as removed in favour of strings, which is accurate, but its diagram still says "FileDatabase.csproj (.NET 9.0)" — the FileDatabase no longer has its own csproj at all (it's a subdirectory of `DeepDrftContent.Services`).
|
||||
**Project-rename drift (the big one).** The two-app split renamed or removed most projects. Any folder `CLAUDE.md` still using the old names is wrong at the structural level, not just the framework-version level:
|
||||
- `DeepDrftWeb` → `DeepDrftPublic`; `DeepDrftWeb.Client` → `DeepDrftPublic.Client`; `DeepDrftWeb.Services` → `DeepDrftData`.
|
||||
- `DeepDrftContent.Services` (class library) is now just `DeepDrftContent`; the old `DeepDrftContent` *host* is gone — binary-API duties split between the `DeepDrftPublic` proxy and the `DeepDrftAPI` authority.
|
||||
- `DeepDrftCli` and the `DeepDrftCms` RCL are **deleted**. Any `CLAUDE.md` for them should be removed, not rewritten.
|
||||
|
||||
**Known content drift to correct in the sweep:**
|
||||
- Framework version: any folder `CLAUDE.md` still saying ".NET 9" / "ASP.NET Core 9.0" — reality is `net10.0` across the board.
|
||||
- `TrackEntity.MediaPath` references (notably the `FileDatabase/README.md`) — the field is `EntryKey`, column `entry_key`.
|
||||
- The `FileDatabase/README.md` refers to `ImageDirectoryVault` (the type is `ImageVault`) and a "FileDatabase.csproj (.NET 9.0)" that no longer exists (FileDatabase is a subdirectory of `DeepDrftContent`).
|
||||
- `DeepDrftData` and `DeepDrftContent` are where most domain logic lives and are the highest-value targets for accurate `CLAUDE.md` coverage.
|
||||
|
||||
**Already corrected (no longer stale):**
|
||||
- `DeepDrftPublic.Client/CLAUDE.md` was rewritten in commit `9110b4b` and reflects the current player stack, `PlaybackIcons`/`PlayStateIcon`, and the post-split structure.
|
||||
|
||||
> **`DOC_PLAN.md` caveat.** `DOC_PLAN.md` predates the two-app split — its per-folder briefs reference `DeepDrftWeb*`, `DeepDrftCli`, and a SQLite backend (now PostgreSQL). Treat its *intent* (lead-with-truth, cross-reference root, no docs for build output) as still valid, but its *project list and per-folder details* need reconciling against the current ten-project solution before doc-keeper executes against it. Flag to Daniel whether to refresh `DOC_PLAN.md` first or let doc-keeper work from the root `CLAUDE.md` directly.
|
||||
|
||||
+37
-18
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. ApiKey middleware for track endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
Dual-database authority for tracks (SQL metadata + FileDatabase binary) and images (FileDatabase binary), and AuthBlocks API host (JWT auth, role/admin seed). Seven track endpoints expose CRUD with upload+persist, delete+cleanup, paged listing, and metadata operations. Two image endpoints provide authenticated upload and unauthenticated streaming. ApiKey middleware for track/image endpoints, JWT + AuthBlocks endpoints for auth. CORS, forwarded headers. **FileDatabase implementation lives in `DeepDrftContent`; SQL services in `DeepDrftData`.**
|
||||
|
||||
## What lives here now (only)
|
||||
|
||||
@@ -22,21 +22,20 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), and Aut
|
||||
|
||||
## What does NOT live here anymore
|
||||
|
||||
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.), `WavOffsetService` — all in `DeepDrftContent` (class library).
|
||||
- `FileDatabase/`, `Processors/`, media models (`AudioBinary`, `ImageBinary`, etc.) — all in `DeepDrftContent` (class library).
|
||||
- EF Core context and repository — in `DeepDrftData`.
|
||||
- **Hosts only own HTTP surface and wiring.** New domain code goes in `*.Services` (shared libraries) or host-internal `Services/` folders (e.g., `UnifiedTrackService` here for dual-database orchestration).
|
||||
|
||||
## The endpoint surface (seven endpoints)
|
||||
|
||||
### GET api/track/{trackId}?offset=0 (unauthenticated)
|
||||
### GET api/track/{trackId} (unauthenticated)
|
||||
|
||||
Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
Returns the WAV bytes from the `tracks` vault with HTTP Range support.
|
||||
|
||||
- **Route parameter `trackId`** (string): the entry id inside the `tracks` vault (i.e. `TrackEntity.EntryKey`).
|
||||
- **Query parameter `offset`** (optional, default 0): byte position to start streaming from.
|
||||
- If `offset == 0`: streams the entire file directly from disk without buffering (so 100 MB WAVs do not force 100 MB LOH allocations per request).
|
||||
- If `offset > 0`: `WavOffsetService.CreateOffsetStream` block-aligns the offset and synthesises a fresh 44-byte WAV header so the response is a valid standalone WAV starting from that byte position. This is load-bearing for seek-beyond-buffer — the player asks for a new stream at the offset it wants to seek to, gets back a valid WAV that starts there, and tears down/re-initialises the decoder.
|
||||
- Returns 404 if track not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
- **Range header** (optional): HTTP Range header for byte-range requests (e.g., `Range: bytes=1000-`). Server responds with `206 Partial Content` and streams from the requested offset.
|
||||
- Streams the file directly from disk with `enableRangeProcessing: true`, supporting both full-file and partial-range requests without synthesizing WAV headers or buffering.
|
||||
- Returns 200 for full-file requests, 206 for Range requests, 404 if track not found, 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
|
||||
### PUT api/track/{trackId} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -51,21 +50,21 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
|
||||
### POST api/track/upload ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Accepts a raw WAV upload + metadata as `multipart/form-data`, processes the WAV, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
**Authenticated endpoint.** Accepts a raw audio file upload (.wav, .mp3, .flac) + metadata as `multipart/form-data`, processes the file, stores it in the vault, and persists metadata to SQL. Returns the fully persisted `TrackDto` with `Id` populated.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form fields**:
|
||||
- `wav` (`IFormFile`, required): the WAV bytes. File name must end in `.wav`.
|
||||
- `audioFile` (`IFormFile`, required): the audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `trackName` (string, required)
|
||||
- `artist` (string, required)
|
||||
- `album` (string, optional)
|
||||
- `genre` (string, optional)
|
||||
- `releaseDate` (string, optional, format `YYYY-MM-DD`)
|
||||
- `createdByUserId` (long, required): audit trail — who uploaded this track.
|
||||
- The upload stream is copied to a `.wav`-suffixed temp file under `Path.GetTempPath()` (the audio processor requires that extension and reads from disk). The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized WAVs are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackService.AddTrackFromWavAsync` (vault write) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields. Returns 500 if processing fails.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 500 if processing fails.
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -104,10 +103,29 @@ Returns the WAV bytes from the `tracks` vault with optional offset support.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`.
|
||||
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request clear optional fields), and persists the DTO via `ITrackService.Update`.
|
||||
- **Body**: `UpdateTrackMetadataRequest` with fields: `TrackName`, `Artist`, `Album?`, `Genre?`, `ReleaseDate?`, `ImagePath?` (tri-state: null = no change, "" = clear, value = set).
|
||||
- Looks up SQL row by ID (returns `TrackDto`), updates the provided fields (nulls in the request for optional metadata clear those fields; `ImagePath` follows tri-state logic), and persists the DTO via `ITrackService.Update`.
|
||||
- Returns 200 with the updated `TrackDto` on success. Returns 404 if track not found. Returns 500 on update error.
|
||||
|
||||
## The image endpoints (two endpoints)
|
||||
|
||||
### POST api/image/upload ([ApiKeyAuthorize])
|
||||
|
||||
**Authenticated endpoint.** Accepts an image file upload, stores it in the `images` vault, and returns the entry key.
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Form field `image`** (`IFormFile`, required): the image bytes (PNG, JPEG, or other format supported by `ImageProcessor`). Maximum file size 50 MB.
|
||||
- Calls `FileDatabase.RegisterResourceAsync("images", entryKey, imageBinary)` where `imageBinary` is produced by `ImageProcessor` (computes aspect ratio from headers, defaults 1.0 for unsupported formats).
|
||||
- Returns 200 with JSON `{ entryKey }` on success. Returns 400 for missing file. Returns 500 if processing or vault operations fail.
|
||||
|
||||
### GET api/image/{entryKey} (unauthenticated)
|
||||
|
||||
Returns image bytes from the `images` vault.
|
||||
|
||||
- **Route parameter `entryKey`** (string): the entry id inside the `images` vault.
|
||||
- Streams the image file directly from disk without buffering.
|
||||
- Returns 404 if image not found. Returns 500 if vault operations fail (with error swallowing — the vault returns `null`).
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
|
||||
`ApiKeyAuthenticationMiddleware` runs on every request but only enforces on endpoints with `[ApiKeyAuthorize]` metadata.
|
||||
@@ -141,7 +159,8 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
|
||||
2. Await `FileDatabase.FromAsync(VaultPath)` to load or create the database.
|
||||
3. Register `FileDatabase` as singleton.
|
||||
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
|
||||
5. Register singletons: `WavOffsetService`, `AudioProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
|
||||
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
@@ -232,7 +251,7 @@ dotnet build DeepDrftAPI
|
||||
curl -H "ApiKey: your-secret-key" -X GET https://localhost:5002/api/track/page \
|
||||
-H "Accept: application/json"
|
||||
|
||||
curl https://localhost:5002/api/track/test-entry-key?offset=0
|
||||
curl https://localhost:5002/api/track/test-entry-key
|
||||
|
||||
# Test auth endpoints (AuthBlocks API)
|
||||
curl -X POST https://localhost:5002/api/auth/login \
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.Processors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImageController : ControllerBase
|
||||
{
|
||||
// 50 MB ceiling — cover art is small, but this is generous headroom for high-res masters.
|
||||
private const int MaxImageBytes = 50_000_000;
|
||||
|
||||
// FileDatabase is injected directly because image operations are vault-only: there is no
|
||||
// SQL row for an image. The link to a track is TrackEntity.ImagePath (the entry key),
|
||||
// written separately via PUT api/track/meta/{id}.
|
||||
private readonly FileDatabase _fileDatabase;
|
||||
private readonly ImageProcessor _imageProcessor;
|
||||
private readonly ILogger<ImageController> _logger;
|
||||
|
||||
public ImageController(
|
||||
FileDatabase fileDatabase,
|
||||
ImageProcessor imageProcessor,
|
||||
ILogger<ImageController> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_imageProcessor = imageProcessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/image/upload ([ApiKeyAuthorize])
|
||||
// Stores a cover-art image in the images vault and returns its generated entry key. Images
|
||||
// are small enough to buffer whole in memory — no temp-file dance like the WAV upload path.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(MaxImageBytes)]
|
||||
public async Task<ActionResult> UploadImage([FromForm] IFormFile? image, CancellationToken cancellationToken)
|
||||
{
|
||||
if (image is null || image.Length == 0)
|
||||
{
|
||||
return BadRequest("Image file is required");
|
||||
}
|
||||
|
||||
if (image.Length > MaxImageBytes)
|
||||
{
|
||||
return BadRequest($"Image exceeds the {MaxImageBytes} byte limit");
|
||||
}
|
||||
|
||||
if (MimeTypeExtensions.GetExtension(image.ContentType) == ".bin")
|
||||
{
|
||||
_logger.LogWarning("UploadImage rejected: unsupported content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
byte[] buffer;
|
||||
await using (var stream = image.OpenReadStream())
|
||||
using (var memory = new MemoryStream())
|
||||
{
|
||||
await stream.CopyToAsync(memory, cancellationToken);
|
||||
buffer = memory.ToArray();
|
||||
}
|
||||
|
||||
var imageBinary = _imageProcessor.Process(buffer, image.ContentType);
|
||||
if (imageBinary is null)
|
||||
{
|
||||
// Process only returns null for an unsupported content type, already screened above —
|
||||
// belt-and-suspenders in case ImageProcessor's validation diverges later.
|
||||
_logger.LogWarning("UploadImage: ImageProcessor rejected content type '{ContentType}'", image.ContentType);
|
||||
return BadRequest($"Unsupported image content type: {image.ContentType}");
|
||||
}
|
||||
|
||||
var entryKey = Guid.NewGuid().ToString("N");
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(VaultConstants.Images, entryKey, imageBinary);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("UploadImage: vault write failed for entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return StatusCode(500, "Failed to store image");
|
||||
}
|
||||
|
||||
_logger.LogInformation("UploadImage succeeded: entryKey={EntryKey}, contentType={ContentType}, size={Size}",
|
||||
entryKey, image.ContentType, buffer.Length);
|
||||
return Ok(new { entryKey });
|
||||
}
|
||||
|
||||
// GET api/image/{entryKey} (unauthenticated)
|
||||
// Streams the image whole from disk. Same disk-streaming pattern as GET api/track/{trackId}
|
||||
// offset-0 path: File() takes ownership of the inner stream on the success path; the wrapper
|
||||
// is disposed only on the catch path.
|
||||
[HttpGet("{entryKey}")]
|
||||
public async Task<ActionResult> GetImage(string entryKey)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Images);
|
||||
if (vault is null)
|
||||
{
|
||||
_logger.LogWarning("Images vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(entryKey);
|
||||
if (mediaStream is null)
|
||||
{
|
||||
_logger.LogWarning("Image not found: {EntryKey}", entryKey);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
string mimeType;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
mimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return File(innerStream, mimeType, enableRangeProcessing: false);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using DeepDrftAPI.Middleware;
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
@@ -15,9 +17,9 @@ namespace DeepDrftAPI.Controllers;
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly DeepDrftContent.TrackContentService _trackContentService;
|
||||
private readonly WavOffsetService _wavOffsetService;
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
||||
@@ -29,16 +31,16 @@ public class TrackController : ControllerBase
|
||||
public TrackController(
|
||||
DeepDrftContent.TrackContentService trackContentService,
|
||||
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
|
||||
WavOffsetService wavOffsetService,
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
_trackContentService = trackContentService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_wavOffsetService = wavOffsetService;
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,17 +48,24 @@ public class TrackController : ControllerBase
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false
|
||||
// GET api/track/page?page=1&pageSize=20&sortColumn=TrackName&sortDescending=false&q=&album=&genre=
|
||||
// Public track listing — paged read straight from SQL. Unauthenticated, like GET api/track/{id}.
|
||||
// q/album/genre build an optional TrackFilter; all null → null passthrough (no filtering).
|
||||
[HttpGet("page")]
|
||||
public async Task<ActionResult> GetPage(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? sortColumn = null,
|
||||
[FromQuery] bool sortDescending = false,
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? album = null,
|
||||
[FromQuery] string? genre = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, cancellationToken);
|
||||
var filter = new TrackFilter { SearchText = q, Album = album, Genre = genre };
|
||||
var effectiveFilter = filter.IsEmpty ? null : filter;
|
||||
|
||||
var result = await _sqlTrackService.GetPaged(page, pageSize, sortColumn, sortDescending, effectiveFilter, cancellationToken);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
@@ -67,11 +76,104 @@ public class TrackController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw WAV in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Used by the CMS upload flow on DeepDrftManager; that host proxies the upload here so it never
|
||||
// touches the vault disk path or SQL directly. UnifiedTrackService owns the two-database write.
|
||||
// GET api/track/albums (unauthenticated)
|
||||
// All releases with per-release track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
// Route name kept as "albums" for client/proxy compatibility; the payload is List<ReleaseDto>.
|
||||
[HttpGet("albums")]
|
||||
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetReleases(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetAlbums failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load albums");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/genres (unauthenticated)
|
||||
// Distinct non-null genres with track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
[HttpGet("genres")]
|
||||
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetDistinctGenres(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetGenres failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load genres");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/random (unauthenticated)
|
||||
// Picks one track at random from the full library and returns its metadata. Public, same auth
|
||||
// posture as GET api/track/page. Selection math lives in the SQL service/repository, not here.
|
||||
// 404 when the library is empty (a valid state the client renders as "no tracks yet"), 200 +
|
||||
// TrackDto otherwise. Literal segment, declared before "{trackId}" so it never routes there.
|
||||
[HttpGet("random")]
|
||||
public async Task<ActionResult> GetRandom(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetRandom(cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetRandom failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/waveform-status ([ApiKeyAuthorize])
|
||||
// Admin backfill view: returns every track with a flag for whether a waveform profile is
|
||||
// stored in the WaveformProfiles vault. The catalogue is small enough that the CMS panel reads
|
||||
// the whole list unpaged. Declared before the parameterized "{trackId}" route so the literal
|
||||
// segment is never treated as a trackId.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("waveform-status")]
|
||||
public async Task<ActionResult> GetWaveformStatus()
|
||||
{
|
||||
var tracks = await _sqlTrackService.GetAll();
|
||||
if (!tracks.Success || tracks.Value is null)
|
||||
{
|
||||
var error = tracks.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetWaveformStatus failed to load tracks: {Error}", error);
|
||||
return StatusCode(500, "Failed to load tracks");
|
||||
}
|
||||
|
||||
var status = new List<WaveformStatusDto>(tracks.Value.Count);
|
||||
foreach (var track in tracks.Value)
|
||||
{
|
||||
var profile = await _waveformProfileService.GetProfileAsync(track.EntryKey);
|
||||
status.Add(new WaveformStatusDto
|
||||
{
|
||||
TrackId = track.Id,
|
||||
EntryKey = track.EntryKey,
|
||||
TrackName = track.TrackName,
|
||||
HasProfile = profile is not null,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
// POST api/track/upload: raw audio in (multipart/form-data) + metadata → persisted TrackDto out.
|
||||
// Accepts .wav, .mp3, and .flac. Used by the CMS upload flow on DeepDrftManager; that host
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
// UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: WAV uploads can be tens to hundreds
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
@@ -80,21 +182,24 @@ public class TrackController : ControllerBase
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? wav,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
[FromForm] string? artist,
|
||||
[FromForm] string? album,
|
||||
[FromForm] string? genre,
|
||||
[FromForm] string? releaseDate,
|
||||
[FromForm] string? originalFileName,
|
||||
[FromForm] long createdByUserId,
|
||||
[FromForm] string? releaseType,
|
||||
[FromForm] int? trackNumber,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, size={Size}",
|
||||
trackName, artist, wav?.Length);
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
|
||||
trackName, artist, originalFileName, audioFile?.Length);
|
||||
|
||||
if (wav is null || wav.Length == 0)
|
||||
if (audioFile is null || audioFile.Length == 0)
|
||||
{
|
||||
return BadRequest("WAV file is required");
|
||||
return BadRequest("Audio file is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackName))
|
||||
@@ -107,9 +212,10 @@ public class TrackController : ControllerBase
|
||||
return BadRequest("artist is required");
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(wav.FileName), ".wav", StringComparison.OrdinalIgnoreCase))
|
||||
var uploadExtension = Path.GetExtension(audioFile.FileName).ToLowerInvariant();
|
||||
if (uploadExtension is not (".wav" or ".mp3" or ".flac"))
|
||||
{
|
||||
return BadRequest("Uploaded file must have a .wav extension");
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
DateOnly? parsedReleaseDate = null;
|
||||
@@ -122,16 +228,33 @@ public class TrackController : ControllerBase
|
||||
parsedReleaseDate = parsed;
|
||||
}
|
||||
|
||||
// AudioProcessor.ProcessWavFileAsync requires a path ending in .wav and reads from disk.
|
||||
// Path.GetTempFileName() yields .tmp, which fails that check — generate our own .wav path.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".wav");
|
||||
// Default to Single for null/unparseable release type; default track number to a valid 1-based value.
|
||||
ReleaseType parsedReleaseType;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType)
|
||||
&& Enum.TryParse<ReleaseType>(releaseType, ignoreCase: true, out var rt)
|
||||
&& Enum.IsDefined(rt))
|
||||
{
|
||||
parsedReleaseType = rt;
|
||||
}
|
||||
else
|
||||
{
|
||||
parsedReleaseType = ReleaseType.Single;
|
||||
if (!string.IsNullOrWhiteSpace(releaseType))
|
||||
_logger.LogWarning("UploadTrack: unrecognised releaseType value '{Value}', defaulting to Single", releaseType);
|
||||
}
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
||||
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = wav.OpenReadStream())
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
@@ -144,11 +267,14 @@ public class TrackController : ControllerBase
|
||||
string.IsNullOrWhiteSpace(genre) ? null : genre,
|
||||
parsedReleaseDate,
|
||||
createdByUserId,
|
||||
string.IsNullOrWhiteSpace(originalFileName) ? null : originalFileName,
|
||||
parsedReleaseType,
|
||||
resolvedTrackNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store WAV";
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
|
||||
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
@@ -198,6 +324,28 @@ public class TrackController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/meta/by-key/{entryKey}: single track metadata by vault entry key.
|
||||
// Unauthenticated, like GET api/track/page and GET api/track/{id} — reachable through the
|
||||
// public proxy. 3-segment route, so no collision with meta/{id:long} or {trackId}.
|
||||
[HttpGet("meta/by-key/{entryKey}")]
|
||||
public async Task<ActionResult> GetMetaByKey(string entryKey)
|
||||
{
|
||||
var result = await _sqlTrackService.GetByEntryKey(entryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetMetaByKey failed for {EntryKey}: {Error}", entryKey, error);
|
||||
return StatusCode(500, "Failed to load track");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// PUT api/track/meta/{id}: metadata-only update. EntryKey is immutable and not part of the body.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("meta/{id:long}")]
|
||||
@@ -216,12 +364,36 @@ public class TrackController : ControllerBase
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (request.TrackNumber is <= 0)
|
||||
return BadRequest("trackNumber must be a positive integer when provided.");
|
||||
|
||||
var track = lookup.Value;
|
||||
|
||||
// Track-cardinal fields update the track row directly.
|
||||
track.TrackName = request.TrackName;
|
||||
track.Artist = request.Artist;
|
||||
track.Album = request.Album;
|
||||
track.Genre = request.Genre;
|
||||
track.ReleaseDate = request.ReleaseDate;
|
||||
if (request.TrackNumber is > 0)
|
||||
track.TrackNumber = request.TrackNumber.Value;
|
||||
|
||||
// Release-cardinal fields update the linked release (handled in TrackManager.Update, which
|
||||
// persists track.Release when the track carries a resolved ReleaseId). The loaded track has
|
||||
// its Release populated via the Include; mutate it in place so the edited values flow through.
|
||||
// A loose track (no release) cannot take release-cardinal edits — there is no release row to
|
||||
// write to — so these fields are simply not persisted in that case.
|
||||
if (track.Release is { } release)
|
||||
{
|
||||
release.Artist = request.Artist;
|
||||
release.Title = request.Album ?? string.Empty;
|
||||
release.Genre = request.Genre;
|
||||
release.ReleaseDate = request.ReleaseDate;
|
||||
|
||||
// ImagePath is tri-state: null = no change, "" = clear, value = set.
|
||||
if (request.ImagePath is not null)
|
||||
release.ImagePath = string.IsNullOrEmpty(request.ImagePath) ? null : request.ImagePath;
|
||||
|
||||
// ReleaseType is non-null on the release; null in the request means "no change".
|
||||
if (request.ReleaseType is not null)
|
||||
release.ReleaseType = request.ReleaseType.Value;
|
||||
}
|
||||
|
||||
var update = await _sqlTrackService.Update(track);
|
||||
if (!update.Success)
|
||||
@@ -259,87 +431,74 @@ public class TrackController : ControllerBase
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// DELETE api/track/release/{id} ([ApiKeyAuthorize])
|
||||
// Soft-delete a release row directly. Used by the albums browser to remove an orphaned release
|
||||
// (one with no live tracks). "release" is a literal segment, declared here in the literal-route
|
||||
// block so it never resolves to the parameterized "{trackId}" GET.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpDelete("release/{id:long}")]
|
||||
public async Task<ActionResult> DeleteRelease(long id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _sqlTrackService.DeleteRelease(id, cancellationToken);
|
||||
if (result.Success) return Ok();
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogError("DeleteRelease failed for id {Id}: {Error}", id, error);
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
// --- Parameterized routes ---
|
||||
|
||||
[HttpGet("{trackId}")]
|
||||
public async Task<ActionResult> GetTrack(string trackId, [FromQuery] long offset = 0)
|
||||
public async Task<ActionResult> GetTrack(string trackId)
|
||||
{
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset);
|
||||
_logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId);
|
||||
|
||||
try
|
||||
{
|
||||
// No-offset path: stream the file straight from disk so a 100 MB WAV does not
|
||||
// force a 100 MB LOH allocation per request. The offset path still loads
|
||||
// the full buffer because WavOffsetService block-aligns and reslices into
|
||||
// a composite stream over the in-memory buffer.
|
||||
if (offset == 0)
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
var vault = _fileDatabase.GetVault(VaultConstants.Tracks);
|
||||
if (vault == null)
|
||||
{
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// enableRangeProcessing: false — seek is served by WavOffsetService, not Range.
|
||||
return File(innerStream, streamMimeType, enableRangeProcessing: false);
|
||||
_logger.LogWarning("Tracks vault not found");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Offset path: route through TrackContentService.GetAudioBinaryAsync (Track B's
|
||||
// orchestrator boundary) so the controller stays out of FileDatabase directly.
|
||||
// The buffered AudioBinary is required because WavOffsetService block-aligns
|
||||
// and reslices into a composite stream over the in-memory buffer.
|
||||
var file = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (file == null)
|
||||
var mediaStream = await vault.GetEntryStreamAsync(trackId);
|
||||
if (mediaStream == null)
|
||||
{
|
||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
|
||||
|
||||
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
|
||||
if (offsetStream == null)
|
||||
// Resolve MIME and log before handing the stream to File().
|
||||
// If anything here throws, the finally block disposes the wrapper
|
||||
// (and its inner FileStream) so neither leaks. On the success path
|
||||
// File() takes ownership of the inner stream; ASP.NET Core disposes
|
||||
// it after the response body is sent. The wrapper is a thin struct
|
||||
// with no extra resources, so disposing it after extracting the
|
||||
// inner stream is a no-op — we only call Dispose() in the catch path.
|
||||
string streamMimeType;
|
||||
long streamLength;
|
||||
Stream innerStream;
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId);
|
||||
return BadRequest("Invalid offset");
|
||||
streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension);
|
||||
streamLength = mediaStream.Stream.Length;
|
||||
innerStream = mediaStream.Stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await mediaStream.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes",
|
||||
trackId, offset, offsetStream.Length);
|
||||
return File(offsetStream, mimeType);
|
||||
_logger.LogInformation(
|
||||
"Streaming track from disk: {TrackId}, Size: {Size} bytes",
|
||||
trackId, streamLength);
|
||||
// enableRangeProcessing: true — seek is served by HTTP Range requests.
|
||||
// The FileStream is seekable, so ASP.NET Core honours an incoming
|
||||
// Range header by slicing the file and responding 206 Partial Content.
|
||||
return File(innerStream, streamMimeType, enableRangeProcessing: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -348,6 +507,54 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// GET api/track/{trackId}/waveform (unauthenticated)
|
||||
// Returns the stored waveform loudness profile for a track, base64-encoded. Public listener
|
||||
// data, same auth posture as GET api/track/{trackId} streaming. 404 when no profile is stored
|
||||
// (existing tracks predate profiling, or computation failed at upload — the frontend falls back
|
||||
// to a flat seekbar). The "waveform" literal suffix keeps this distinct from the audio route.
|
||||
[HttpGet("{trackId}/waveform")]
|
||||
public async Task<ActionResult> GetWaveform(string trackId)
|
||||
{
|
||||
var bytes = await _waveformProfileService.GetProfileAsync(trackId);
|
||||
if (bytes is null)
|
||||
{
|
||||
_logger.LogInformation("No waveform profile for track: {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(new WaveformProfileDto
|
||||
{
|
||||
BucketCount = bytes.Length,
|
||||
Data = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
|
||||
// POST api/track/{trackId}/waveform ([ApiKeyAuthorize])
|
||||
// Admin backfill: compute and store a waveform profile for an existing track from its vault
|
||||
// audio. trackId is the EntryKey. 404 when no audio is stored under that key; 500 when the
|
||||
// WAV cannot be decoded or the vault write fails. Used by the CMS PreProcessing panel for
|
||||
// tracks that predate the WaveformSeeker feature.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{trackId}/waveform")]
|
||||
public async Task<ActionResult> GenerateWaveform(string trackId)
|
||||
{
|
||||
var audio = await _trackContentService.GetAudioBinaryAsync(trackId);
|
||||
if (audio is null)
|
||||
{
|
||||
_logger.LogWarning("GenerateWaveform: no audio in vault for {TrackId}", trackId);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stored = await _waveformProfileService.ComputeAndStoreAsync(audio.Buffer, trackId);
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogError("GenerateWaveform: profile computation/storage failed for {TrackId}", trackId);
|
||||
return StatusCode(500, "Failed to generate waveform profile.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPut("{trackId}")]
|
||||
public async Task<ActionResult> PutTrack(string trackId, [FromBody] AudioBinaryDto track)
|
||||
|
||||
@@ -26,4 +26,3 @@
|
||||
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftAPI.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Body of <c>PUT api/track/meta/{id}</c>. Metadata-only — EntryKey is immutable and never
|
||||
/// travels over this surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="ImagePath"/> follows tri-state semantics distinct from the other optional
|
||||
/// fields: <c>null</c> leaves the existing value unchanged, an empty string clears it, and a
|
||||
/// non-empty value is the images-vault entry key to link.
|
||||
/// </remarks>
|
||||
public record UpdateTrackMetadataRequest(
|
||||
string TrackName,
|
||||
string Artist,
|
||||
string? Album,
|
||||
string? Genre,
|
||||
DateOnly? ReleaseDate);
|
||||
DateOnly? ReleaseDate,
|
||||
string? ImagePath = null,
|
||||
ReleaseType? ReleaseType = null,
|
||||
int? TrackNumber = null);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
@@ -18,24 +20,28 @@ public class UnifiedTrackService
|
||||
private readonly TrackContentService _contentTrackContentService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly ILogger<UnifiedTrackService> _logger;
|
||||
|
||||
public UnifiedTrackService(
|
||||
TrackContentService contentTrackContentService,
|
||||
ITrackService sqlTrackService,
|
||||
FileDb fileDatabase,
|
||||
WaveformProfileService waveformProfileService,
|
||||
ILogger<UnifiedTrackService> logger)
|
||||
{
|
||||
_contentTrackContentService = contentTrackContentService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_fileDatabase = fileDatabase;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a WAV into the vault, then persist its metadata to SQL. On success the returned
|
||||
/// DTO carries the SQL-assigned Id. If the vault write succeeds but the SQL persist fails,
|
||||
/// the audio is orphaned under EntryKey — logged loudly so it is recoverable manually.
|
||||
/// Process a supported audio file (.wav, .mp3, .flac) into the vault, then persist its metadata
|
||||
/// to SQL. On success the returned DTO carries the SQL-assigned Id. If the vault write succeeds
|
||||
/// but the SQL persist fails, the audio is orphaned under EntryKey — logged loudly so it is
|
||||
/// recoverable manually.
|
||||
/// </summary>
|
||||
public async Task<ResultContainer<TrackDto>> UploadAsync(
|
||||
string tempFilePath,
|
||||
@@ -45,10 +51,13 @@ public class UnifiedTrackService
|
||||
string? genre,
|
||||
DateOnly? releaseDate,
|
||||
long createdByUserId,
|
||||
string? originalFileName,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var unpersisted = await _contentTrackContentService.AddTrackFromWavAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate);
|
||||
var unpersisted = await _contentTrackContentService.AddTrackAsync(
|
||||
tempFilePath, trackName, artist, album, genre, releaseDate, originalFileName: originalFileName);
|
||||
|
||||
if (unpersisted is null)
|
||||
{
|
||||
@@ -56,9 +65,43 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult("Failed to process and store WAV.");
|
||||
}
|
||||
|
||||
unpersisted.CreatedByUserId = createdByUserId;
|
||||
unpersisted.TrackNumber = trackNumber;
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(TrackConverter.Convert(unpersisted));
|
||||
// Resolve the release FK before persisting the track. An upload with an album lands on the
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/releaseDate/type/uploader) rides
|
||||
// on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
var releaseData = new ReleaseDto
|
||||
{
|
||||
Title = album,
|
||||
Artist = artist,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate,
|
||||
ReleaseType = releaseType,
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success || releaseResult.Value is null)
|
||||
{
|
||||
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
"Track persisted to vault but release resolution failed. Orphaned entry: {EntryKey}. Error: {Error}",
|
||||
unpersisted.EntryKey, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
releaseId = releaseResult.Value.Id;
|
||||
}
|
||||
|
||||
var trackDto = TrackConverter.Convert(unpersisted);
|
||||
trackDto.ReleaseId = releaseId;
|
||||
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(trackDto);
|
||||
if (!saveResult.Success || saveResult.Value is null)
|
||||
{
|
||||
// Vault write succeeded, SQL persist failed — audio is orphaned in the tracks vault
|
||||
@@ -70,9 +113,27 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
// Best-effort waveform profile: both stores succeeded, so the upload is a success
|
||||
// regardless of the profile outcome. A missing profile renders as a flat seekbar on the
|
||||
// frontend, so a failure here is logged and swallowed — never fails the upload.
|
||||
await TryStoreWaveformProfileAsync(tempFilePath, unpersisted.EntryKey, ct);
|
||||
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
private async Task TryStoreWaveformProfileAsync(string tempFilePath, string entryKey, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wavBytes = await File.ReadAllBytesAsync(tempFilePath, ct);
|
||||
await _waveformProfileService.ComputeAndStoreAsync(wavBytes, entryKey);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform profile step failed for {EntryKey}; upload unaffected.", entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track's SQL row, then its vault entry. SQL is the source of truth: a SQL delete
|
||||
/// failure fails the operation (and leaves the vault untouched), but a subsequent vault delete
|
||||
@@ -94,6 +155,7 @@ public class UnifiedTrackService
|
||||
}
|
||||
|
||||
var entryKey = lookup.Value.EntryKey;
|
||||
var releaseId = lookup.Value.ReleaseId;
|
||||
|
||||
var sqlDelete = await _sqlTrackService.Delete(id);
|
||||
if (!sqlDelete.Success)
|
||||
@@ -103,6 +165,14 @@ public class UnifiedTrackService
|
||||
return Result.CreateFailResult("Failed to delete track.");
|
||||
}
|
||||
|
||||
// Cascade: if this was the last live track on its release, soft-delete the release too so it
|
||||
// does not linger as a 0-track orphan in the albums browser. Non-fatal — the track delete
|
||||
// already succeeded, so any failure here is logged and swallowed, not surfaced to the caller.
|
||||
if (releaseId is { } rid)
|
||||
{
|
||||
await TrySoftDeleteEmptyReleaseAsync(rid, ct);
|
||||
}
|
||||
|
||||
// Tri-state per FileDatabase's error-swallow contract: null = vault missing/error,
|
||||
// false = entry not present, true = removed. Anything but a clean removal is an orphan.
|
||||
var removed = await _fileDatabase.RemoveResourceAsync(VaultConstants.Tracks, entryKey);
|
||||
@@ -115,4 +185,30 @@ public class UnifiedTrackService
|
||||
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
// Soft-delete the release only when no live tracks remain on it. Best-effort: a count or delete
|
||||
// failure here never fails the track delete that triggered it — it is logged so an orphaned
|
||||
// release can be cleaned up later (the migration backfill also catches pre-existing orphans).
|
||||
private async Task TrySoftDeleteEmptyReleaseAsync(long releaseId, CancellationToken ct)
|
||||
{
|
||||
var countResult = await _sqlTrackService.CountLiveTracksByRelease(releaseId, ct);
|
||||
if (!countResult.Success)
|
||||
{
|
||||
var error = countResult.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: live-track count failed for release {ReleaseId}: {Error}", releaseId, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (countResult.Value > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var releaseDelete = await _sqlTrackService.DeleteRelease(releaseId, ct);
|
||||
if (!releaseDelete.Success)
|
||||
{
|
||||
var error = releaseDelete.Messages.FirstOrDefault()?.Message ?? "unknown error";
|
||||
_logger.LogWarning("DeleteAsync: release soft-delete failed for {ReleaseId}: {Error}", releaseId, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -1,6 +1,5 @@
|
||||
using DeepDrftAPI.Models;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Audio;
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using DeepDrftContent.FileDatabase.Services;
|
||||
@@ -15,10 +14,21 @@ namespace DeepDrftAPI
|
||||
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
|
||||
{
|
||||
// Audio services
|
||||
builder.Services.AddSingleton<WavOffsetService>();
|
||||
builder.Services.AddSingleton<AudioProcessor>();
|
||||
builder.Services.AddSingleton<Mp3AudioProcessor>();
|
||||
builder.Services.AddSingleton<FlacAudioProcessor>();
|
||||
builder.Services.AddSingleton<AudioProcessorRouter>();
|
||||
builder.Services.AddSingleton<TrackContentService>();
|
||||
|
||||
// Image services
|
||||
builder.Services.AddSingleton<ImageProcessor>();
|
||||
|
||||
// Waveform loudness profiling (upload-time, off the playback path)
|
||||
builder.Services.Configure<WaveformProfileOptions>(
|
||||
builder.Configuration.GetSection(nameof(WaveformProfileOptions)));
|
||||
builder.Services.AddSingleton<ILoudnessAlgorithm, RmsLoudnessAlgorithm>();
|
||||
builder.Services.AddSingleton<WaveformProfileService>();
|
||||
|
||||
// File Database
|
||||
var fileDatabasePath = CredentialTools.ResolvePathOrThrow("filedatabase", "environment/filedatabase.json");
|
||||
builder.Configuration.AddJsonFile(fileDatabasePath, optional: false, reloadOnChange: false);
|
||||
@@ -32,6 +42,7 @@ namespace DeepDrftAPI
|
||||
var db = FileDatabase.FromAsync(vaultPath, logger).GetAwaiter().GetResult();
|
||||
if (db is null) throw new Exception("Unable to initialize file database");
|
||||
InitializeTrackVault(db).GetAwaiter().GetResult();
|
||||
InitializeImageVault(db).GetAwaiter().GetResult();
|
||||
return db;
|
||||
});
|
||||
|
||||
@@ -45,5 +56,13 @@ namespace DeepDrftAPI
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Tracks, MediaVaultType.Audio);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InitializeImageVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Images))
|
||||
{
|
||||
await fileDatabase.CreateVaultAsync(VaultConstants.Images, MediaVaultType.Image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating WAV audio streams starting from a byte offset.
|
||||
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||
/// </summary>
|
||||
public class WavOffsetService
|
||||
{
|
||||
/// <summary>
|
||||
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
|
||||
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
|
||||
/// (format 3) and other formats are rejected at parse time so the
|
||||
/// synthesized header here can safely assume PCM.
|
||||
/// </summary>
|
||||
public const short PcmFormat = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
|
||||
/// buffer — no copy of the audio payload is made.
|
||||
/// </summary>
|
||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||
{
|
||||
var format = ParseWavHeader(fullAudioBuffer);
|
||||
if (format == null)
|
||||
return null;
|
||||
|
||||
// Validate offset is within bounds and block-aligned
|
||||
if (byteOffset < 0 || byteOffset >= format.DataSize)
|
||||
return null;
|
||||
|
||||
// Align to block boundary for clean audio
|
||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||
|
||||
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||
var newDataSize = format.DataSize - alignedOffset;
|
||||
if (newDataSize <= 0)
|
||||
return null;
|
||||
|
||||
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
||||
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
|
||||
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
|
||||
|
||||
var newDataSizeInt = (int)newDataSize;
|
||||
var sourcePositionInt = (int)sourcePosition;
|
||||
|
||||
// Create new WAV header using the format reported by the parsed header.
|
||||
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
|
||||
// threading format.AudioFormat through keeps the header self-consistent
|
||||
// and prevents drift if the validation contract is ever relaxed.
|
||||
var newHeader = CreateWavHeader(format, newDataSizeInt);
|
||||
|
||||
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
|
||||
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
|
||||
// that the previous MemoryStream(capacity).Write(...) implementation forced.
|
||||
var headerStream = new MemoryStream(newHeader, writable: false);
|
||||
var dataStream = new MemoryStream(
|
||||
fullAudioBuffer,
|
||||
sourcePositionInt,
|
||||
newDataSizeInt,
|
||||
writable: false,
|
||||
publiclyVisible: false);
|
||||
|
||||
return new ConcatStream(headerStream, dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WAV header from a buffer to extract format information.
|
||||
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
|
||||
/// so downstream synthesis can safely assume PCM sample encoding.
|
||||
/// </summary>
|
||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
return null;
|
||||
|
||||
// Check RIFF header
|
||||
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riff != "RIFF")
|
||||
return null;
|
||||
|
||||
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (wave != "WAVE")
|
||||
return null;
|
||||
|
||||
// Variables to store parsed header info
|
||||
int sampleRate = 0;
|
||||
int channels = 0;
|
||||
int bitsPerSample = 0;
|
||||
int byteRate = 0;
|
||||
int blockAlign = 0;
|
||||
long dataSize = 0;
|
||||
int headerSize = 0;
|
||||
short audioFormat = 0;
|
||||
bool foundFmt = false;
|
||||
bool foundData = false;
|
||||
|
||||
// Find fmt and data chunks
|
||||
int chunkOffset = 12;
|
||||
while (chunkOffset < buffer.Length - 8)
|
||||
{
|
||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||
|
||||
if (chunkSize < 0)
|
||||
return null;
|
||||
|
||||
if (chunkId == "fmt " && !foundFmt)
|
||||
{
|
||||
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
|
||||
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
|
||||
// AudioProcessor.FindChunk which also returns the first match.
|
||||
if (chunkSize < 16)
|
||||
return null;
|
||||
|
||||
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||
// header below is PCM-shaped — accepting Float would produce a corrupt file
|
||||
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
|
||||
// non-PCM at upload time so this branch is defense in depth.
|
||||
if (audioFormat != PcmFormat)
|
||||
return null;
|
||||
|
||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
|
||||
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
|
||||
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
|
||||
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
|
||||
|
||||
// Basic validation
|
||||
if (channels < 1 || channels > 8)
|
||||
return null;
|
||||
|
||||
foundFmt = true;
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
|
||||
// values above int.MaxValue (files between 2–4 GB), then widen to long.
|
||||
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
|
||||
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||
foundData = true;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment (chunks are word-aligned)
|
||||
chunkOffset += 8 + ((chunkSize + 1) & ~1);
|
||||
|
||||
// If we found both chunks, we're done
|
||||
if (foundFmt && foundData)
|
||||
break;
|
||||
}
|
||||
|
||||
// Must have found both fmt and data chunks
|
||||
if (!foundFmt || !foundData)
|
||||
return null;
|
||||
|
||||
return new WavFormat(
|
||||
AudioFormat: audioFormat,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitsPerSample: bitsPerSample,
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
DataSize: dataSize,
|
||||
HeaderSize: headerSize
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||
/// </summary>
|
||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||
{
|
||||
var header = new byte[44];
|
||||
var fileSize = 36 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
|
||||
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
|
||||
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
|
||||
|
||||
// fmt chunk
|
||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
||||
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
|
||||
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
|
||||
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
|
||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
|
||||
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
|
||||
|
||||
// data chunk header
|
||||
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
|
||||
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV format information extracted from header.
|
||||
/// </summary>
|
||||
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||
public record WavFormat(
|
||||
short AudioFormat,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
int BitsPerSample,
|
||||
int ByteRate,
|
||||
int BlockAlign,
|
||||
long DataSize,
|
||||
int HeaderSize
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only read stream over two underlying streams concatenated end-to-end.
|
||||
/// Lets us serve "[synthesized header][slice of original buffer]" without
|
||||
/// allocating a single contiguous buffer for the combined payload.
|
||||
/// </summary>
|
||||
internal sealed class ConcatStream : Stream
|
||||
{
|
||||
private readonly Stream _first;
|
||||
private readonly Stream _second;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ConcatStream(Stream first, Stream second)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_length = first.Length + second.Length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
|
||||
// is full. Stream.Read is not required to fill the buffer in one call even
|
||||
// when data is available (e.g. a future non-MemoryStream _first), so we must
|
||||
// keep pulling until we get 0 before advancing to _second.
|
||||
while (count > 0 && _position < _first.Length)
|
||||
{
|
||||
var read = _first.Read(buffer, offset, count);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
var read = _second.Read(buffer, offset, count);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Same loop contract as Read() — exhaust _first before reading _second.
|
||||
while (!buffer.IsEmpty && _position < _first.Length)
|
||||
{
|
||||
var read = await _first.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
buffer = buffer[read..];
|
||||
}
|
||||
|
||||
if (!buffer.IsEmpty)
|
||||
{
|
||||
var read = await _second.ReadAsync(buffer, cancellationToken);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_first.Dispose();
|
||||
_second.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
+17
-22
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
|
||||
|
||||
## One-line purpose
|
||||
|
||||
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), WAV stream-with-offset, audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
|
||||
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -17,8 +17,6 @@ DeepDrftContent.Services/
|
||||
│ ├── Models/ # Data models, DTOs, enums
|
||||
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
|
||||
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
|
||||
├── Audio/
|
||||
│ └── WavOffsetService.cs # Byte offset → valid WAV stream
|
||||
├── Processors/
|
||||
│ └── AudioProcessor.cs # WAV file parsing, metadata extraction
|
||||
├── Constants/
|
||||
@@ -76,30 +74,28 @@ public async Task<bool> RegisterResourceAsync(string vaultId, string entryId, Fi
|
||||
|
||||
**Callers must check return values.** Do not change this without a deliberate design pass — it's embedded in all FileDatabase tests and client code.
|
||||
|
||||
## WAV offset service
|
||||
## Audio processors
|
||||
|
||||
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
|
||||
Multi-format support via router pattern. All processors live in `DeepDrftContent/Processors/`:
|
||||
|
||||
1. Parses the WAV header from the buffer.
|
||||
2. Block-aligns the byte offset to the nearest block boundary (required for clean audio — misalignment causes clicks).
|
||||
3. Synthesises a new 44-byte WAV header sized for the remaining data (from offset to EOF).
|
||||
4. Returns a `MemoryStream` containing `[new header][data from offset]`.
|
||||
- `AudioProcessor.ProcessWavFileAsync(filePath)`: WAV-specific processor. Validates RIFF/WAVE structure and format code. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing. Parses fmt and data chunks; extracts duration and bitrate. Returns `AudioBinary` with metadata. On parse failure, logs warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo). Accepts standard PCM (audioFormat=1), WAVE_FORMAT_EXTENSIBLE with PCM SubFormat (0x0001), IEEE Float SubFormat (0x0003), and Padded 24-in-32 containers; normalizes Float and padded inputs to standard 24-bit PCM before storage.
|
||||
- `Mp3AudioProcessor.ProcessMp3FileAsync(filePath)`: MP3 processor. Skips ID3v2 tag, finds first valid MPEG frame sync, decodes frame header (bitrate, sample rate, channels). Reads Xing/Info header for VBR total-frame count (accurate duration); VBRI header as fallback; CBR estimate from file size otherwise. Returns `AudioBinary` with original bytes and `.mp3` extension. On parse failure, falls back to defaults (180s / 320 kbps).
|
||||
- `FlacAudioProcessor.ProcessFlacFileAsync(filePath)`: FLAC processor. Validates `fLaC` magic, reads STREAMINFO metadata block (20-bit sample rate, 3-bit channels, 5-bit bits-per-sample, 36-bit total samples — all bit-packed). Computes duration from `totalSamples / sampleRate`; average bitrate from file size. Returns `AudioBinary` with original bytes and `.flac` extension. On parse failure, falls back to defaults (180s / 1411 kbps).
|
||||
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)`: Routes by extension — `.wav` → `AudioProcessor`, `.mp3` → `Mp3AudioProcessor`, `.flac` → `FlacAudioProcessor`. Throws `ArgumentException` for unsupported extensions.
|
||||
|
||||
Used by the content API to serve seek-beyond-buffer requests. The player asks for a new stream at the byte offset it wants to seek to; the server returns a valid WAV that starts there.
|
||||
Vault stores original bytes with correct extension and MIME type (inferred from file extension or content-type header at upload time).
|
||||
|
||||
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
|
||||
The primary entry point is `TrackContentService.AddTrackAsync(filePath, mimeType)` — format-agnostic. It selects the right processor via `AudioProcessorRouter`, processes the file, generates an entry GUID, stores in vault, returns unpersisted `TrackEntity`. Legacy `AddTrackFromWavAsync(filePath)` is now a shim over `AddTrackAsync` for backward compatibility.
|
||||
|
||||
## Audio processor
|
||||
## Image processor
|
||||
|
||||
`AudioProcessor.ProcessWavFileAsync(filePath)`:
|
||||
`ImageProcessor.ProcessImageAsync(buffer, mimeType)`:
|
||||
|
||||
1. Validates the RIFF/WAVE/PCM structure.
|
||||
2. Parses the fmt and data chunks.
|
||||
3. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
|
||||
4. Returns `AudioBinary` with all metadata.
|
||||
5. **Fallback**: If parsing fails, logs a warning and returns defaults (180s / 1411 kbps / 44.1 kHz / 16-bit stereo).
|
||||
|
||||
PCM-only today. Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. The processor validates RIFF/WAVE/PCM format — anything else is rejected.
|
||||
1. Accepts raw image bytes and MIME type (e.g., `image/png`, `image/jpeg`).
|
||||
2. Parses PNG or JPEG headers to extract image dimensions.
|
||||
3. Computes aspect ratio (width / height). Defaults to 1.0 if parsing fails or format is unsupported.
|
||||
4. Returns `ImageBinary` with MIME type and aspect ratio metadata.
|
||||
5. **No disk I/O**: operates on `byte[]` only — no file reading required.
|
||||
|
||||
## Content-side TrackService (orchestrator)
|
||||
|
||||
@@ -124,14 +120,13 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
|
||||
|
||||
## Vault constants
|
||||
|
||||
`VaultConstants.Tracks = "tracks"` — the one vault name in production use. New vault names go here when adding new vault types (e.g., `VaultConstants.Images = "images"` if image uploads are added).
|
||||
`VaultConstants.Tracks = "tracks"` and `VaultConstants.Images = "images"` — the vault names in production use. New vault names go here when adding new vault types.
|
||||
|
||||
## Service registration
|
||||
|
||||
In `DeepDrftContent/Startup.ConfigureDomainServices()` and `DeepDrftCli/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddSingleton<WavOffsetService>();
|
||||
services.AddSingleton<FileDatabase>(/* from FileDatabase.FromAsync */);
|
||||
services.AddScoped<AudioProcessor>();
|
||||
services.AddScoped<TrackService>(); // DeepDrftContent.Services.TrackService
|
||||
|
||||
@@ -9,4 +9,15 @@ public static class VaultConstants
|
||||
/// Vault name for storing audio tracks
|
||||
/// </summary>
|
||||
public const string Tracks = "tracks";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for storing waveform loudness profile sidecars, keyed by track EntryKey.
|
||||
/// </summary>
|
||||
public const string WaveformProfiles = "waveform-profiles";
|
||||
|
||||
/// <summary>
|
||||
/// Vault name for storing cover-art images, keyed by a generated entry key referenced
|
||||
/// from <c>TrackEntity.ImagePath</c>.
|
||||
/// </summary>
|
||||
public const string Images = "images";
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -219,6 +219,32 @@ public class AudioVault : MediaVault
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete vault for plain <see cref="MediaBinary"/> entries (vault type
|
||||
/// <see cref="MediaVaultType.Media"/>) — bytes plus an extension, no audio/image-specific
|
||||
/// metadata. Used for sidecar artifacts such as waveform loudness profiles. The base
|
||||
/// <see cref="MediaVault"/> already handles Media-typed storage via the registry; this only
|
||||
/// provides the concrete factory the Image and Audio vaults also provide.
|
||||
/// </summary>
|
||||
public class MediaFileVault : MediaVault
|
||||
{
|
||||
private MediaFileVault(string rootPath, VaultIndex index, IndexFactoryService? factoryService = null)
|
||||
: base(rootPath, index, factoryService) { }
|
||||
|
||||
public static async Task<MediaFileVault?> FromAsync(string rootPath, IndexFactoryService? factoryService = null)
|
||||
{
|
||||
var factory = factoryService ?? new IndexFactoryService();
|
||||
var index = await factory.LoadOrCreateVaultIndexAsync(rootPath, MediaVaultType.Media);
|
||||
|
||||
if (index != null)
|
||||
{
|
||||
return new MediaFileVault(rootPath, (VaultIndex)index, factory);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open read-only stream over a vault entry plus the extension needed to
|
||||
/// resolve its MIME type. Caller owns the stream and must dispose it.
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class MediaVaultFactory
|
||||
{
|
||||
return mediaType switch
|
||||
{
|
||||
MediaVaultType.Media => await MediaFileVault.FromAsync(rootPath, factoryService),
|
||||
MediaVaultType.Image => await ImageVault.FromAsync(rootPath, factoryService),
|
||||
MediaVaultType.Audio => await AudioVault.FromAsync(rootPath, factoryService),
|
||||
_ => null
|
||||
|
||||
@@ -31,7 +31,8 @@ public class SimpleMediaTypeRegistry : IMediaTypeRegistry
|
||||
dto => MediaBinary.From(dto),
|
||||
binary => new MediaBinaryDto(binary),
|
||||
(key, ext, _) => new MetaData(key, ext),
|
||||
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension));
|
||||
(binary, meta) => new MediaBinaryParams(binary.Buffer, binary.Size, meta.Extension),
|
||||
async path => await MediaFileVault.FromAsync(path));
|
||||
|
||||
RegisterType<ImageBinary, ImageBinaryParams, ImageBinaryDto, ImageMetaData>(
|
||||
MediaVaultType.Image,
|
||||
|
||||
@@ -28,10 +28,15 @@ public class AudioProcessor
|
||||
{
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var wavInfo = ExtractWavMetadata(buffer);
|
||||
|
||||
|
||||
// EXTENSIBLE-PCM is byte-compatible with standard PCM but carries a 40+ byte fmt chunk
|
||||
// the streaming pipeline never expects. Normalize to a plain 44-byte PCM WAV at storage
|
||||
// time so the vault only ever holds standard PCM and the client decode path stays unchanged.
|
||||
var storedBuffer = wavInfo.IsExtensible ? NormalizeToStandardPcm(buffer, wavInfo) : buffer;
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Buffer: storedBuffer,
|
||||
Size: storedBuffer.Length,
|
||||
Extension: ".wav",
|
||||
Duration: wavInfo.Duration,
|
||||
Bitrate: wavInfo.Bitrate
|
||||
@@ -45,6 +50,67 @@ public class AudioProcessor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the raw PCM data region and format parameters from a WAV buffer, reusing the
|
||||
/// same chunk-walk and validation as metadata extraction. Returns null if the buffer is not
|
||||
/// a valid PCM WAV (callers treat a null as "no profile computable" and continue) — unlike
|
||||
/// <see cref="ExtractWavMetadata"/>, this does NOT fall back to synthetic defaults, because a
|
||||
/// loudness profile over fabricated silence would be misleading.
|
||||
/// </summary>
|
||||
public PcmData? TryExtractPcm(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
// Copy the span to an array so the existing array-based parsers can be reused. The PCM
|
||||
// slice returned is a view over this array (no second copy of the data region).
|
||||
var bytes = buffer.ToArray();
|
||||
|
||||
var validation = ValidateWavStructure(bytes);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Float and padded-container EXTENSIBLE require a sample-level transform to become integer PCM.
|
||||
// TryExtractPcm feeds loudness analysis, not storage, and must not hand back float bytes
|
||||
// mislabeled as integer PCM — out of scope here, so treat them as "no profile computable".
|
||||
if (validation.IsFloat)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WavMetadata metadata;
|
||||
try
|
||||
{
|
||||
metadata = ParseWavMetadata(bytes, validation);
|
||||
ValidateAudioParameters(metadata);
|
||||
if (metadata.IsPaddedContainer)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Data bytes begin 8 past the "data" chunk id (4 id + 4 size). Clamp the declared size to
|
||||
// what is actually present — some encoders write a size that overshoots the file.
|
||||
var dataStart = validation.DataChunkPos + 8;
|
||||
if (dataStart > bytes.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var available = bytes.Length - dataStart;
|
||||
var dataLength = Math.Min(metadata.DataSize, available);
|
||||
if (dataLength <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pcm = new ReadOnlyMemory<byte>(bytes, dataStart, dataLength);
|
||||
return new PcmData(pcm, metadata.Channels, metadata.SampleRate, metadata.BitsPerSample);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from WAV file buffer with comprehensive validation
|
||||
/// </summary>
|
||||
@@ -107,9 +173,46 @@ public class AudioProcessor
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "fmt chunk too small" };
|
||||
}
|
||||
|
||||
// Validate audio format (PCM only)
|
||||
// Validate audio format. Standard PCM (1) is accepted directly. WAVE_FORMAT_EXTENSIBLE
|
||||
// (0xFFFE) is accepted when its SubFormat GUID indicates PCM (0x0001) or IEEE float
|
||||
// (0x0003). PCM sample data is byte-identical to standard PCM; float data is converted to
|
||||
// 24-bit PCM downstream. Either way the vault only ever holds standard PCM.
|
||||
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
|
||||
if (audioFormat != 1)
|
||||
var isExtensible = false;
|
||||
var isFloat = false;
|
||||
if (audioFormat == 0xFFFE)
|
||||
{
|
||||
// EXTENSIBLE requires the full extension: 16 base + 2 cbSize + 22 extension = 40 bytes.
|
||||
if (fmtChunkSize < 40)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk too small" };
|
||||
}
|
||||
|
||||
if (fmtChunkPos + 8 + 40 > buffer.Length)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE fmt chunk extends past end of file" };
|
||||
}
|
||||
|
||||
// SubFormat GUID begins 24 bytes into the fmt chunk data (fmtChunkPos + 8 + 24). Its
|
||||
// first two bytes are the little-endian format tag: 0x0001 == WAVE_FORMAT_PCM,
|
||||
// 0x0003 == WAVE_FORMAT_IEEE_FLOAT.
|
||||
var subFormatPos = fmtChunkPos + 8 + 24;
|
||||
var subFormatTag = BitConverter.ToUInt16(buffer, subFormatPos);
|
||||
if (subFormatTag == 0x0001)
|
||||
{
|
||||
isExtensible = true;
|
||||
}
|
||||
else if (subFormatTag == 0x0003)
|
||||
{
|
||||
isExtensible = true;
|
||||
isFloat = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is neither PCM nor IEEE float" };
|
||||
}
|
||||
}
|
||||
else if (audioFormat != 1)
|
||||
{
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
|
||||
}
|
||||
@@ -121,11 +224,13 @@ public class AudioProcessor
|
||||
return new WavValidationResult { IsValid = false, ErrorMessage = "Missing data chunk" };
|
||||
}
|
||||
|
||||
return new WavValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
return new WavValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
FmtChunkPos = fmtChunkPos,
|
||||
DataChunkPos = dataChunkPos
|
||||
DataChunkPos = dataChunkPos,
|
||||
IsExtensible = isExtensible,
|
||||
IsFloat = isFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +246,23 @@ public class AudioProcessor
|
||||
var bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 22);
|
||||
var dataSize = BitConverter.ToUInt32(buffer, validation.DataChunkPos + 4);
|
||||
|
||||
// For EXTENSIBLE the offset-22 field is the container width; the true sample depth lives in
|
||||
// wValidBitsPerSample (fmtChunkPos + 8 + 18). They usually match (Bandcamp 24-bit = 24/24)
|
||||
// but the valid bits are authoritative for the normalized header and metadata. When they
|
||||
// differ (e.g. 24-bit valid in a 32-bit container) we keep the container width separately so
|
||||
// ValidateAudioParameters can reconcile against the header BlockAlign and NormalizeToStandardPcm
|
||||
// can re-pack the padded frames.
|
||||
var containerBitsPerSample = 0;
|
||||
if (validation.IsExtensible)
|
||||
{
|
||||
var validBits = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
|
||||
if (validBits != bitsPerSample)
|
||||
{
|
||||
containerBitsPerSample = bitsPerSample;
|
||||
}
|
||||
bitsPerSample = validBits;
|
||||
}
|
||||
|
||||
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
|
||||
var bitrate = (int)((sampleRate * channels * bitsPerSample) / 1000);
|
||||
|
||||
@@ -151,8 +273,12 @@ public class AudioProcessor
|
||||
SampleRate = (int)sampleRate,
|
||||
Channels = channels,
|
||||
BitsPerSample = bitsPerSample,
|
||||
ContainerBitsPerSample = containerBitsPerSample,
|
||||
BlockAlign = blockAlign,
|
||||
DataSize = (int)dataSize
|
||||
DataSize = (int)dataSize,
|
||||
DataChunkPos = validation.DataChunkPos,
|
||||
IsExtensible = validation.IsExtensible,
|
||||
IsFloat = validation.IsFloat
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,13 +305,140 @@ public class AudioProcessor
|
||||
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
|
||||
}
|
||||
|
||||
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
|
||||
// The header BlockAlign reflects the container width, not the valid bit depth. For a padded
|
||||
// EXTENSIBLE container (e.g. 24-in-32) the container width is authoritative for this check;
|
||||
// NormalizeToStandardPcm re-packs the frames down to the valid depth afterwards.
|
||||
var blockAlignBits = metadata.IsPaddedContainer ? metadata.ContainerBitsPerSample : metadata.BitsPerSample;
|
||||
var expectedBlockAlign = metadata.Channels * (blockAlignBits / 8);
|
||||
if (metadata.BlockAlign != expectedBlockAlign)
|
||||
{
|
||||
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an EXTENSIBLE WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1)
|
||||
/// so the vault only ever holds a format the streaming pipeline already handles. Three source
|
||||
/// shapes are normalized:
|
||||
/// <list type="bullet">
|
||||
/// <item>EXTENSIBLE-PCM (depth == container): sample bytes are byte-identical to standard PCM and
|
||||
/// copied verbatim; only the header is replaced.</item>
|
||||
/// <item>IEEE float: 32-bit float samples are converted to 24-bit signed integer PCM.</item>
|
||||
/// <item>Padded container (e.g. 24-in-32): the padding/sign-extension bytes are stripped, keeping
|
||||
/// the lowest valid bytes per sample.</item>
|
||||
/// </list>
|
||||
/// The output header always reports the valid bit depth (<see cref="WavMetadata.BitsPerSample"/>).
|
||||
/// </summary>
|
||||
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
|
||||
{
|
||||
// Clamp the declared data size to what is actually present; some encoders overshoot.
|
||||
var dataStart = metadata.DataChunkPos + 8;
|
||||
var available = buffer.Length - dataStart;
|
||||
var srcDataSize = Math.Min(metadata.DataSize, available);
|
||||
|
||||
byte[] dataBytes;
|
||||
int outBitsPerSample;
|
||||
if (metadata.IsFloat)
|
||||
{
|
||||
dataBytes = ConvertFloatTo24BitPcm(buffer, dataStart, srcDataSize);
|
||||
outBitsPerSample = 24;
|
||||
}
|
||||
else if (metadata.IsPaddedContainer)
|
||||
{
|
||||
dataBytes = RepackPaddedContainer(buffer, dataStart, srcDataSize, metadata.ContainerBitsPerSample, metadata.BitsPerSample);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
else
|
||||
{
|
||||
dataBytes = new byte[srcDataSize];
|
||||
Array.Copy(buffer, dataStart, dataBytes, 0, srcDataSize);
|
||||
outBitsPerSample = metadata.BitsPerSample;
|
||||
}
|
||||
|
||||
var dataSize = dataBytes.Length;
|
||||
const int headerSize = 44;
|
||||
var result = new byte[headerSize + dataSize];
|
||||
|
||||
var blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
|
||||
var byteRate = (uint)(metadata.SampleRate * blockAlign);
|
||||
|
||||
// RIFF header
|
||||
System.Text.Encoding.ASCII.GetBytes("RIFF").CopyTo(result, 0);
|
||||
BitConverter.GetBytes((uint)(36 + dataSize)).CopyTo(result, 4);
|
||||
System.Text.Encoding.ASCII.GetBytes("WAVE").CopyTo(result, 8);
|
||||
|
||||
// fmt chunk (standard 16-byte PCM)
|
||||
System.Text.Encoding.ASCII.GetBytes("fmt ").CopyTo(result, 12);
|
||||
BitConverter.GetBytes((uint)16).CopyTo(result, 16);
|
||||
BitConverter.GetBytes((ushort)1).CopyTo(result, 20); // audioFormat = PCM
|
||||
BitConverter.GetBytes((ushort)metadata.Channels).CopyTo(result, 22);
|
||||
BitConverter.GetBytes((uint)metadata.SampleRate).CopyTo(result, 24);
|
||||
BitConverter.GetBytes(byteRate).CopyTo(result, 28);
|
||||
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
|
||||
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
|
||||
|
||||
// data chunk
|
||||
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
|
||||
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
|
||||
|
||||
Array.Copy(dataBytes, 0, result, headerSize, dataSize);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts 32-bit little-endian IEEE float samples (range [-1.0, 1.0]) to 24-bit signed PCM.
|
||||
/// Each 4-byte source sample becomes 3 little-endian output bytes; output size is 3/4 of input.
|
||||
/// Trailing bytes that do not form a complete 4-byte sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] ConvertFloatTo24BitPcm(byte[] buffer, int dataStart, int dataSize)
|
||||
{
|
||||
var sampleCount = dataSize / 4;
|
||||
var output = new byte[sampleCount * 3];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var sample = BitConverter.ToSingle(buffer, dataStart + i * 4);
|
||||
var value = (int)(sample * 8388607.0);
|
||||
value = Math.Clamp(value, -8388608, 8388607);
|
||||
|
||||
var o = i * 3;
|
||||
output[o] = (byte)(value & 0xFF);
|
||||
output[o + 1] = (byte)((value >> 8) & 0xFF);
|
||||
output[o + 2] = (byte)((value >> 16) & 0xFF);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips container padding from a padded-container EXTENSIBLE WAV (e.g. 24-bit valid samples
|
||||
/// stored in 32-bit containers), keeping only the lowest <paramref name="validBits"/> bytes of
|
||||
/// each little-endian sample. Output size is (validBits/containerBits) of input.
|
||||
/// Trailing bytes that do not form a complete container sample are ignored.
|
||||
/// </summary>
|
||||
private static byte[] RepackPaddedContainer(byte[] buffer, int dataStart, int dataSize, int containerBits, int validBits)
|
||||
{
|
||||
var containerBytes = containerBits / 8;
|
||||
var validBytes = validBits / 8;
|
||||
var sampleCount = dataSize / containerBytes;
|
||||
var output = new byte[sampleCount * validBytes];
|
||||
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
var src = dataStart + i * containerBytes;
|
||||
var dst = i * validBytes;
|
||||
// Little-endian: the valid sample occupies the low bytes; the upper bytes are padding /
|
||||
// sign extension and are discarded.
|
||||
for (int b = 0; b < validBytes; b++)
|
||||
{
|
||||
output[dst + b] = buffer[src + b];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default WAV metadata for fallback scenarios
|
||||
/// </summary>
|
||||
@@ -253,9 +506,26 @@ public class AudioProcessor
|
||||
public int Bitrate { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
|
||||
/// <summary>The valid sample depth — for EXTENSIBLE, wValidBitsPerSample.</summary>
|
||||
public int BitsPerSample { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The container sample width for a padded EXTENSIBLE WAV whose valid depth is narrower
|
||||
/// (e.g. 32 for a 24-in-32 file). Zero when the container matches the valid depth.
|
||||
/// </summary>
|
||||
public int ContainerBitsPerSample { get; set; }
|
||||
|
||||
public int BlockAlign { get; set; }
|
||||
public int DataSize { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the SubFormat is IEEE float (converted to 24-bit PCM on normalization).</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
|
||||
/// <summary>True when valid samples are stored in a wider container that must be re-packed.</summary>
|
||||
public bool IsPaddedContainer => ContainerBitsPerSample != 0 && ContainerBitsPerSample != BitsPerSample;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -267,5 +537,23 @@ public class AudioProcessor
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
public int FmtChunkPos { get; set; }
|
||||
public int DataChunkPos { get; set; }
|
||||
public bool IsExtensible { get; set; }
|
||||
|
||||
/// <summary>True when the EXTENSIBLE SubFormat is IEEE float rather than PCM.</summary>
|
||||
public bool IsFloat { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The raw PCM sample region of a WAV plus the format parameters needed to interpret it.
|
||||
/// <see cref="Pcm"/> is a view over the decoded buffer — the data chunk only, header excluded.
|
||||
/// </summary>
|
||||
/// <param name="Pcm">The PCM sample bytes (interleaved by channel, little-endian).</param>
|
||||
/// <param name="Channels">Number of interleaved channels.</param>
|
||||
/// <param name="SampleRate">Samples per second.</param>
|
||||
/// <param name="BitsPerSample">Bit depth per sample (8, 16, 24, or 32).</param>
|
||||
public readonly record struct PcmData(
|
||||
ReadOnlyMemory<byte> Pcm,
|
||||
int Channels,
|
||||
int SampleRate,
|
||||
int BitsPerSample);
|
||||
@@ -0,0 +1,42 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches an audio file to the correct format processor by extension. The single seam through
|
||||
/// which <see cref="TrackContentService"/> processes uploads, so callers depend on one abstraction
|
||||
/// rather than three concrete processors.
|
||||
/// </summary>
|
||||
public class AudioProcessorRouter
|
||||
{
|
||||
private readonly AudioProcessor _wavProcessor;
|
||||
private readonly Mp3AudioProcessor _mp3Processor;
|
||||
private readonly FlacAudioProcessor _flacProcessor;
|
||||
|
||||
public AudioProcessorRouter(
|
||||
AudioProcessor wavProcessor,
|
||||
Mp3AudioProcessor mp3Processor,
|
||||
FlacAudioProcessor flacProcessor)
|
||||
{
|
||||
_wavProcessor = wavProcessor;
|
||||
_mp3Processor = mp3Processor;
|
||||
_flacProcessor = flacProcessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes <paramref name="filePath"/> with the processor matching its extension, returning an
|
||||
/// <see cref="AudioBinary"/> carrying the stored bytes and extracted metadata. Throws
|
||||
/// <see cref="ArgumentException"/> for unsupported extensions.
|
||||
/// </summary>
|
||||
public async Task<AudioBinary?> ProcessAudioFileAsync(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".wav" => await _wavProcessor.ProcessWavFileAsync(filePath),
|
||||
".mp3" => await _mp3Processor.ProcessMp3FileAsync(filePath),
|
||||
".flac" => await _flacProcessor.ProcessFlacFileAsync(filePath),
|
||||
_ => throw new ArgumentException($"Unsupported audio format: {ext}", nameof(filePath)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from a FLAC file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.flac</c>. No transcoding — the vault stores the original
|
||||
/// stream; duration and average bitrate come from the mandatory STREAMINFO metadata block.
|
||||
/// </summary>
|
||||
public class FlacAudioProcessor
|
||||
{
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 1411;
|
||||
|
||||
public async Task<AudioBinary?> ProcessFlacFileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"FLAC file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be a FLAC file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractFlacMetadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".flac",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the <c>fLaC</c> magic and the leading STREAMINFO block, then computes duration from
|
||||
/// total-samples / sample-rate and average bitrate from file size. On any parse failure, logs a
|
||||
/// warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static FlacMetadata ExtractFlacMetadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Magic (4) + metadata block header (4) + STREAMINFO data (34) = 42 bytes minimum.
|
||||
if (buffer.Length < 42)
|
||||
{
|
||||
throw new InvalidDataException("File too short for FLAC STREAMINFO");
|
||||
}
|
||||
|
||||
if (buffer[0] != 'f' || buffer[1] != 'L' || buffer[2] != 'a' || buffer[3] != 'C')
|
||||
{
|
||||
throw new InvalidDataException("Invalid fLaC magic");
|
||||
}
|
||||
|
||||
// Metadata block header at offset 4: bits 6-0 of byte 0 are the block type (0 = STREAMINFO).
|
||||
var blockType = buffer[4] & 0x7F;
|
||||
if (blockType != 0)
|
||||
{
|
||||
throw new InvalidDataException($"First metadata block is not STREAMINFO (type {blockType})");
|
||||
}
|
||||
|
||||
// STREAMINFO data begins at offset 8. Layout (bit-packed, big-endian):
|
||||
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
|
||||
// bits 3-1 of byte 12: channels - 1
|
||||
// bit 0 of byte 12 + top 4 bits of byte 13: bits per sample - 1
|
||||
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
|
||||
var d = 8;
|
||||
var sampleRate = (buffer[d + 10] << 12) | (buffer[d + 11] << 4) | (buffer[d + 12] >> 4);
|
||||
var totalSamples = ((long)(buffer[d + 13] & 0x0F) << 32)
|
||||
| ((long)buffer[d + 14] << 24)
|
||||
| ((long)buffer[d + 15] << 16)
|
||||
| ((long)buffer[d + 16] << 8)
|
||||
| buffer[d + 17];
|
||||
|
||||
if (sampleRate <= 0)
|
||||
{
|
||||
throw new InvalidDataException("Invalid FLAC sample rate");
|
||||
}
|
||||
|
||||
var duration = (double)totalSamples / sampleRate;
|
||||
var bitrate = duration > 0
|
||||
? (int)(buffer.LongLength * 8L / (duration * 1000))
|
||||
: FallbackBitrate;
|
||||
|
||||
return new FlacMetadata { Duration = duration, Bitrate = bitrate };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: FLAC parsing failed, using defaults: {ex.Message}");
|
||||
return new FlacMetadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FlacMetadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for reducing a stream of PCM samples to a fixed-length, peak-normalized loudness
|
||||
/// envelope. Swappable so the loudness measure (RMS today, LUFS later) can change without
|
||||
/// touching <c>WaveformProfileService</c>, the stored wire format, or the frontend renderer.
|
||||
/// </summary>
|
||||
public interface ILoudnessAlgorithm
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a peak-normalized loudness profile from raw interleaved PCM.
|
||||
/// </summary>
|
||||
/// <param name="pcmData">Interleaved, little-endian PCM sample bytes (the WAV data chunk).</param>
|
||||
/// <param name="channels">Number of interleaved channels; averaged to mono per sample.</param>
|
||||
/// <param name="sampleRate">Samples per second (unused by RMS but part of the contract for measures that need it).</param>
|
||||
/// <param name="bitsPerSample">Bit depth (8 unsigned, 16/24/32 signed) used to decode samples.</param>
|
||||
/// <param name="bucketCount">Number of equal time slices to reduce the signal to.</param>
|
||||
/// <returns>
|
||||
/// A <c>double[bucketCount]</c>, each value in [0, 1], peak-normalized so the loudest bucket
|
||||
/// is 1. All zeros when the signal is silent (peak is 0) or no samples are present.
|
||||
/// </returns>
|
||||
double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Buffers.Binary;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Processes raw image bytes into an <see cref="ImageBinary"/>, mirroring the shape of
|
||||
/// <see cref="AudioProcessor"/>. Validates the content type resolves to a known image
|
||||
/// extension, derives the aspect ratio from the image dimensions where cheaply parseable
|
||||
/// (PNG, JPEG), and defaults to 1.0 for formats whose headers we don't parse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Operates entirely in memory — no disk I/O. Follows the FileDatabase error-handling
|
||||
/// philosophy: dimension parsing logs a warning and falls back to a best-effort aspect
|
||||
/// ratio of 1.0 rather than throwing. Content-type rejection is a caller-facing validation
|
||||
/// failure (returns null), distinct from a parse hiccup.
|
||||
/// </remarks>
|
||||
public class ImageProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an <see cref="ImageBinary"/> from raw image bytes and a MIME content type.
|
||||
/// Returns null when the content type does not resolve to a recognised image extension
|
||||
/// (the <c>.bin</c> sentinel from <see cref="MimeTypeExtensions.GetExtension"/>).
|
||||
/// </summary>
|
||||
public ImageBinary? Process(byte[] imageBytes, string contentType)
|
||||
{
|
||||
var extension = MimeTypeExtensions.GetExtension(contentType);
|
||||
if (extension == ".bin")
|
||||
{
|
||||
Console.WriteLine($"Warning: ImageProcessor rejected unsupported content type '{contentType}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
var aspectRatio = ComputeAspectRatio(imageBytes, extension);
|
||||
|
||||
var parameters = new ImageBinaryParams(
|
||||
Buffer: imageBytes,
|
||||
Size: imageBytes.Length,
|
||||
Extension: extension,
|
||||
AspectRatio: aspectRatio);
|
||||
|
||||
return new ImageBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives width/height from the format header and returns width/height. Defaults to 1.0
|
||||
/// for unparsed formats (gif, webp, bmp, svg) and on any parse failure.
|
||||
/// </summary>
|
||||
private static double ComputeAspectRatio(byte[] bytes, string extension)
|
||||
{
|
||||
try
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".png" => ParsePngAspectRatio(bytes),
|
||||
".jpg" or ".jpeg" => ParseJpegAspectRatio(bytes),
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: image dimension parsing failed for '{extension}', defaulting aspect ratio to 1.0: {ex.Message}");
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PNG: the IHDR chunk places width at bytes 16–19 and height at 20–23, both big-endian
|
||||
/// uint32. Guards on the "PNG" signature at bytes 1–3.
|
||||
/// </summary>
|
||||
private static double ParsePngAspectRatio(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length < 24 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G')
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var width = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(16, 4));
|
||||
var height = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(20, 4));
|
||||
return Ratio(width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JPEG: walk the marker segments from byte 2 looking for SOF0 (0xFF 0xC0) or SOF2
|
||||
/// (0xFF 0xC2). Height is a big-endian uint16 at marker+5, width at marker+7. Guards on
|
||||
/// the SOI marker (0xFF 0xD8) at bytes 0–1.
|
||||
/// </summary>
|
||||
private static double ParseJpegAspectRatio(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
var pos = 2;
|
||||
while (pos + 9 < bytes.Length)
|
||||
{
|
||||
// Marker segments begin with 0xFF; skip any fill bytes before the marker id.
|
||||
if (bytes[pos] != 0xFF)
|
||||
{
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var marker = bytes[pos + 1];
|
||||
if (marker == 0xC0 || marker == 0xC2)
|
||||
{
|
||||
var height = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 5, 2));
|
||||
var width = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 7, 2));
|
||||
return Ratio(width, height);
|
||||
}
|
||||
|
||||
// Standalone markers (RSTn, SOI, EOI, TEM) carry no length payload; everything
|
||||
// else has a 2-byte big-endian segment length immediately after the marker id.
|
||||
if (marker is 0xD8 or 0xD9 or 0x01 || (marker >= 0xD0 && marker <= 0xD7))
|
||||
{
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(pos + 2, 2));
|
||||
pos += 2 + segmentLength;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static double Ratio(uint width, uint height) => height == 0 ? 1.0 : (double)width / height;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts metadata from an MP3 file and wraps its <b>unmodified</b> bytes in an
|
||||
/// <see cref="AudioBinary"/> tagged <c>.mp3</c>. No transcoding — the vault stores the original
|
||||
/// stream; only duration/bitrate metadata are computed from the first MPEG frame header (plus a
|
||||
/// Xing/VBRI tag when present for accurate VBR duration).
|
||||
/// </summary>
|
||||
public class Mp3AudioProcessor
|
||||
{
|
||||
// MPEG1 Layer III bitrate table (kbps), indexed by the 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg1Layer3Bitrates =
|
||||
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
|
||||
|
||||
// MPEG2/2.5 Layer III bitrate table (kbps), indexed by 4-bit bitrate index. 0 = free, 15 = bad.
|
||||
private static readonly int[] Mpeg2Layer3Bitrates =
|
||||
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
|
||||
|
||||
private static readonly int[] Mpeg1SampleRates = [44100, 48000, 32000];
|
||||
private static readonly int[] Mpeg2SampleRates = [22050, 24000, 16000];
|
||||
private static readonly int[] Mpeg25SampleRates = [11025, 12000, 8000];
|
||||
|
||||
private const double FallbackDuration = 180.0;
|
||||
private const int FallbackBitrate = 320;
|
||||
|
||||
public async Task<AudioBinary?> ProcessMp3FileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"MP3 file not found: {filePath}");
|
||||
}
|
||||
|
||||
if (!Path.GetExtension(filePath).Equals(".mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("File must be an MP3 file", nameof(filePath));
|
||||
}
|
||||
|
||||
var buffer = await File.ReadAllBytesAsync(filePath);
|
||||
var meta = ExtractMp3Metadata(buffer);
|
||||
|
||||
var parameters = new AudioBinaryParams(
|
||||
Buffer: buffer,
|
||||
Size: buffer.Length,
|
||||
Extension: ".mp3",
|
||||
Duration: meta.Duration,
|
||||
Bitrate: meta.Bitrate);
|
||||
|
||||
return new AudioBinary(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the first valid MPEG frame (after any ID3v2 tag) and any Xing/VBRI tag inside it.
|
||||
/// On any parse failure, logs a warning and returns synthetic defaults — never throws.
|
||||
/// </summary>
|
||||
private static Mp3Metadata ExtractMp3Metadata(byte[] buffer)
|
||||
{
|
||||
try
|
||||
{
|
||||
var frameStart = FindFirstFrame(buffer);
|
||||
if (frameStart < 0)
|
||||
{
|
||||
throw new InvalidDataException("No valid MPEG frame sync found");
|
||||
}
|
||||
|
||||
var header = DecodeFrameHeader(buffer, frameStart);
|
||||
var duration = ComputeDuration(buffer, frameStart, header);
|
||||
|
||||
return new Mp3Metadata { Duration = duration, Bitrate = header.BitrateKbps };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: MP3 parsing failed, using defaults: {ex.Message}");
|
||||
return new Mp3Metadata { Duration = FallbackDuration, Bitrate = FallbackBitrate };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the offset of the first valid MPEG frame, skipping a leading ID3v2 tag if present.
|
||||
/// Scans for a 0xFF / 0xE0-syncword pair and fully validates the 4-byte header before accepting.
|
||||
/// </summary>
|
||||
private static int FindFirstFrame(byte[] buffer)
|
||||
{
|
||||
var start = SkipId3v2(buffer);
|
||||
|
||||
for (int i = start; i < buffer.Length - 4; i++)
|
||||
{
|
||||
if (buffer[i] != 0xFF || (buffer[i + 1] & 0xE0) != 0xE0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsValidFrameHeader(buffer, i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the byte offset just past an ID3v2 tag, or 0 if none. The tag size is a syncsafe
|
||||
/// big-endian uint28 at bytes 6–9 (each byte's MSB is 0). A footer (flag bit 4 of byte 5) adds 10.
|
||||
/// </summary>
|
||||
private static int SkipId3v2(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 10 || buffer[0] != 'I' || buffer[1] != 'D' || buffer[2] != '3')
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var size = (buffer[6] << 21) | (buffer[7] << 14) | (buffer[8] << 7) | buffer[9];
|
||||
var skip = 10 + size;
|
||||
if ((buffer[5] & 0x10) != 0)
|
||||
{
|
||||
skip += 10; // footer present
|
||||
}
|
||||
|
||||
return skip <= buffer.Length ? skip : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fully validates a candidate 4-byte frame header: layer must be III, and version, bitrate
|
||||
/// index, and sample-rate index must all be non-reserved (rejects free bitrate, bad index 0xF,
|
||||
/// and reserved sample rate 3).
|
||||
/// </summary>
|
||||
private static bool IsValidFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
if (versionBits == 1) // 1 = reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var layerBits = (b1 >> 1) & 0x03;
|
||||
if (layerBits != 1) // 1 = Layer III; this processor handles Layer III only
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
if (bitrateIndex == 0 || bitrateIndex == 0x0F) // 0 = free, 0xF = bad
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
if (sampleRateIndex == 3) // reserved
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static FrameHeader DecodeFrameHeader(byte[] buffer, int pos)
|
||||
{
|
||||
var b1 = buffer[pos + 1];
|
||||
var b2 = buffer[pos + 2];
|
||||
var b3 = buffer[pos + 3];
|
||||
|
||||
var versionBits = (b1 >> 3) & 0x03;
|
||||
var version = versionBits switch
|
||||
{
|
||||
3 => MpegVersion.Mpeg1,
|
||||
2 => MpegVersion.Mpeg2,
|
||||
_ => MpegVersion.Mpeg25, // 0 = MPEG2.5
|
||||
};
|
||||
|
||||
var bitrateIndex = (b2 >> 4) & 0x0F;
|
||||
var bitrateTable = version == MpegVersion.Mpeg1 ? Mpeg1Layer3Bitrates : Mpeg2Layer3Bitrates;
|
||||
var bitrateKbps = bitrateTable[bitrateIndex];
|
||||
|
||||
var sampleRateIndex = (b2 >> 2) & 0x03;
|
||||
var sampleRate = version switch
|
||||
{
|
||||
MpegVersion.Mpeg1 => Mpeg1SampleRates[sampleRateIndex],
|
||||
MpegVersion.Mpeg2 => Mpeg2SampleRates[sampleRateIndex],
|
||||
_ => Mpeg25SampleRates[sampleRateIndex],
|
||||
};
|
||||
|
||||
var channelMode = (b3 >> 6) & 0x03;
|
||||
var channels = channelMode == 3 ? 1 : 2;
|
||||
var samplesPerFrame = version == MpegVersion.Mpeg1 ? 1152 : 576;
|
||||
|
||||
return new FrameHeader
|
||||
{
|
||||
Version = version,
|
||||
BitrateKbps = bitrateKbps,
|
||||
SampleRate = sampleRate,
|
||||
Channels = channels,
|
||||
SamplesPerFrame = samplesPerFrame,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes duration from a Xing/Info or VBRI tag (accurate for VBR) when present; otherwise
|
||||
/// falls back to the CBR estimate fileSize / (bitrate_kbps * 125). Guards divide-by-zero.
|
||||
/// </summary>
|
||||
private static double ComputeDuration(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var xingFrames = ReadXingFrameCount(buffer, frameStart, header);
|
||||
if (xingFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)xingFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
var vbriFrames = ReadVbriFrameCount(buffer, frameStart);
|
||||
if (vbriFrames > 0 && header.SampleRate > 0)
|
||||
{
|
||||
return (double)vbriFrames * header.SamplesPerFrame / header.SampleRate;
|
||||
}
|
||||
|
||||
// CBR fallback: bitrate_kbps * 1000 / 8 bytes per second = bitrate_kbps * 125.
|
||||
// Exclude the ID3v2 tag bytes (everything before frameStart) from the estimate.
|
||||
var bytesPerSecond = header.BitrateKbps * 125;
|
||||
return bytesPerSecond > 0 ? (double)(buffer.Length - frameStart) / bytesPerSecond : FallbackDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Xing/Info VBR total-frame count from the side-information region of the first frame,
|
||||
/// or 0 if no Xing tag or no frame-count flag. Side-info offset depends on version and channels.
|
||||
/// </summary>
|
||||
private static int ReadXingFrameCount(byte[] buffer, int frameStart, FrameHeader header)
|
||||
{
|
||||
var sideInfoSize = header.Version == MpegVersion.Mpeg1
|
||||
? (header.Channels == 1 ? 17 : 32)
|
||||
: (header.Channels == 1 ? 9 : 17);
|
||||
|
||||
var tagPos = frameStart + 4 + sideInfoSize;
|
||||
if (tagPos + 12 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "Xing") && !MatchesAscii(buffer, tagPos, "Info"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var flags = ReadUInt32BigEndian(buffer, tagPos + 4);
|
||||
if ((flags & 0x01) == 0) // bit 0 = frame-count present
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Fraunhofer VBRI total-frame count. The VBRI tag sits at a fixed offset 32 past the
|
||||
/// frame header (frameStart + 4 + 32); the frame count is a big-endian uint32 at tag offset 14.
|
||||
/// </summary>
|
||||
private static int ReadVbriFrameCount(byte[] buffer, int frameStart)
|
||||
{
|
||||
var tagPos = frameStart + 4 + 32;
|
||||
if (tagPos + 18 > buffer.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!MatchesAscii(buffer, tagPos, "VBRI"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ReadUInt32BigEndian(buffer, tagPos + 14);
|
||||
}
|
||||
|
||||
private static bool MatchesAscii(byte[] buffer, int pos, string tag)
|
||||
{
|
||||
for (int i = 0; i < tag.Length; i++)
|
||||
{
|
||||
if (buffer[pos + i] != (byte)tag[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32BigEndian(byte[] buffer, int pos) =>
|
||||
((uint)buffer[pos] << 24) | ((uint)buffer[pos + 1] << 16) | ((uint)buffer[pos + 2] << 8) | buffer[pos + 3];
|
||||
|
||||
private enum MpegVersion
|
||||
{
|
||||
Mpeg1,
|
||||
Mpeg2,
|
||||
Mpeg25,
|
||||
}
|
||||
|
||||
private sealed class FrameHeader
|
||||
{
|
||||
public MpegVersion Version { get; init; }
|
||||
public int BitrateKbps { get; init; }
|
||||
public int SampleRate { get; init; }
|
||||
public int Channels { get; init; }
|
||||
public int SamplesPerFrame { get; init; }
|
||||
}
|
||||
|
||||
private sealed class Mp3Metadata
|
||||
{
|
||||
public double Duration { get; init; }
|
||||
public int Bitrate { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Loudness via root-mean-square amplitude per time bucket. Decodes signed PCM (8-bit unsigned,
|
||||
/// 16/24/32-bit signed little-endian), averages channels to mono, partitions the frames into
|
||||
/// equal time slices, takes the RMS of each slice, then peak-normalizes so the loudest bucket is 1.
|
||||
/// No external audio dependency — operates directly on the WAV data-chunk bytes.
|
||||
/// </summary>
|
||||
public class RmsLoudnessAlgorithm : ILoudnessAlgorithm
|
||||
{
|
||||
public double[] Compute(ReadOnlySpan<byte> pcmData, int channels, int sampleRate, int bitsPerSample, int bucketCount)
|
||||
{
|
||||
if (bucketCount <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bucketCount), "Bucket count must be positive.");
|
||||
}
|
||||
|
||||
var result = new double[bucketCount];
|
||||
|
||||
if (channels <= 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var bytesPerSample = bitsPerSample / 8;
|
||||
if (bytesPerSample <= 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var bytesPerFrame = bytesPerSample * channels;
|
||||
var frameCount = pcmData.Length / bytesPerFrame;
|
||||
if (frameCount == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sum of squared mono amplitudes and the frame count, per bucket. A frame's bucket is
|
||||
// determined by its position in the timeline so buckets are equal-duration slices.
|
||||
var sumSquares = new double[bucketCount];
|
||||
var counts = new long[bucketCount];
|
||||
|
||||
for (var frame = 0; frame < frameCount; frame++)
|
||||
{
|
||||
var frameStart = frame * bytesPerFrame;
|
||||
|
||||
double channelSum = 0;
|
||||
for (var ch = 0; ch < channels; ch++)
|
||||
{
|
||||
var sampleStart = frameStart + ch * bytesPerSample;
|
||||
channelSum += ReadSampleNormalized(pcmData, sampleStart, bitsPerSample);
|
||||
}
|
||||
|
||||
var mono = channelSum / channels;
|
||||
|
||||
// long math avoids overflow on large files before the divide back into bucket index.
|
||||
var bucket = (int)((long)frame * bucketCount / frameCount);
|
||||
if (bucket >= bucketCount)
|
||||
{
|
||||
bucket = bucketCount - 1;
|
||||
}
|
||||
|
||||
sumSquares[bucket] += mono * mono;
|
||||
counts[bucket]++;
|
||||
}
|
||||
|
||||
var peak = 0.0;
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
if (counts[i] > 0)
|
||||
{
|
||||
result[i] = Math.Sqrt(sumSquares[i] / counts[i]);
|
||||
if (result[i] > peak)
|
||||
{
|
||||
peak = result[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (peak <= 0)
|
||||
{
|
||||
// Silence — return all zeros (Array is already zero-initialized).
|
||||
Array.Clear(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (var i = 0; i < bucketCount; i++)
|
||||
{
|
||||
result[i] /= peak;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one PCM sample at <paramref name="offset"/> to a normalized amplitude in [-1, 1].
|
||||
/// 8-bit is unsigned (0..255, centered at 128); 16/24/32-bit are signed little-endian.
|
||||
/// </summary>
|
||||
private static double ReadSampleNormalized(ReadOnlySpan<byte> data, int offset, int bitsPerSample)
|
||||
{
|
||||
switch (bitsPerSample)
|
||||
{
|
||||
case 8:
|
||||
// Unsigned, midpoint 128.
|
||||
return (data[offset] - 128) / 128.0;
|
||||
|
||||
case 16:
|
||||
{
|
||||
short sample = (short)(data[offset] | (data[offset + 1] << 8));
|
||||
return sample / 32768.0;
|
||||
}
|
||||
|
||||
case 24:
|
||||
{
|
||||
// Sign-extend the 24-bit little-endian value into an int.
|
||||
int raw = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16);
|
||||
if ((raw & 0x800000) != 0)
|
||||
{
|
||||
raw |= unchecked((int)0xFF000000);
|
||||
}
|
||||
return raw / 8388608.0;
|
||||
}
|
||||
|
||||
case 32:
|
||||
{
|
||||
int sample = data[offset]
|
||||
| (data[offset + 1] << 8)
|
||||
| (data[offset + 2] << 16)
|
||||
| (data[offset + 3] << 24);
|
||||
return sample / 2147483648.0;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(bitsPerSample), bitsPerSample, "Unsupported PCM bit depth.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for waveform loudness profiling. <see cref="BucketCount"/> is the stored
|
||||
/// resolution — the number of loudness buckets computed and persisted per track, which is also
|
||||
/// the bar count the frontend WaveformSeeker renders.
|
||||
/// </summary>
|
||||
public class WaveformProfileOptions
|
||||
{
|
||||
public int BucketCount { get; set; } = 512;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using DeepDrftContent.Constants;
|
||||
using DeepDrftContent.FileDatabase.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftContent.Processors;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a track's waveform loudness profile from its WAV bytes and persists it as a sidecar
|
||||
/// in the <see cref="VaultConstants.WaveformProfiles"/> vault, keyed by the track's EntryKey.
|
||||
/// The profile is the upload-time, off-the-playback-path representation the frontend fetches to
|
||||
/// render the WaveformSeeker. The loudness measure is injected (<see cref="ILoudnessAlgorithm"/>)
|
||||
/// so it can be swapped without changing storage or the wire format.
|
||||
/// </summary>
|
||||
public class WaveformProfileService
|
||||
{
|
||||
private const string ProfileExtension = ".wfp";
|
||||
|
||||
private readonly FileDb _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
private readonly ILoudnessAlgorithm _loudnessAlgorithm;
|
||||
private readonly WaveformProfileOptions _options;
|
||||
private readonly ILogger<WaveformProfileService> _logger;
|
||||
|
||||
public WaveformProfileService(
|
||||
FileDb fileDatabase,
|
||||
AudioProcessor audioProcessor,
|
||||
ILoudnessAlgorithm loudnessAlgorithm,
|
||||
IOptions<WaveformProfileOptions> options,
|
||||
ILogger<WaveformProfileService> logger)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
_loudnessAlgorithm = loudnessAlgorithm;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the loudness profile from <paramref name="wavBytes"/> and stores it under
|
||||
/// <paramref name="entryKey"/>. Returns false (and logs) on any failure — a missing profile
|
||||
/// is handled gracefully downstream, so callers on the upload path log-and-continue rather
|
||||
/// than failing the upload. Does not throw for expected failure modes.
|
||||
/// </summary>
|
||||
public async Task<bool> ComputeAndStoreAsync(ReadOnlyMemory<byte> wavBytes, string entryKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pcm = _audioProcessor.TryExtractPcm(wavBytes.Span);
|
||||
if (pcm is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Waveform profile not computed for {EntryKey}: WAV PCM could not be extracted.",
|
||||
entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = pcm.Value;
|
||||
var profile = _loudnessAlgorithm.Compute(
|
||||
value.Pcm.Span,
|
||||
value.Channels,
|
||||
value.SampleRate,
|
||||
value.BitsPerSample,
|
||||
_options.BucketCount);
|
||||
|
||||
var quantized = Quantize(profile);
|
||||
|
||||
await EnsureVaultAsync();
|
||||
|
||||
var binary = new MediaBinary(new MediaBinaryParams(quantized, quantized.Length, ProfileExtension));
|
||||
var stored = await _fileDatabase.RegisterResourceAsync(
|
||||
VaultConstants.WaveformProfiles, entryKey, binary);
|
||||
|
||||
if (!stored)
|
||||
{
|
||||
_logger.LogWarning("Waveform profile vault write failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Waveform profile computation failed for {EntryKey}.", entryKey);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the stored quantized profile bytes for a track, or null if no profile is stored
|
||||
/// (existing tracks predate profiling, and computation may have failed). Each byte is a
|
||||
/// peak-normalized loudness value in [0, 255].
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetProfileAsync(string entryKey)
|
||||
{
|
||||
var binary = await _fileDatabase.LoadResourceAsync<MediaBinary>(
|
||||
VaultConstants.WaveformProfiles, entryKey);
|
||||
return binary?.Buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each [0, 1] bucket to a [0, 255] byte. 1.0 maps to 255; the multiply-by-255 with a
|
||||
/// truncating cast keeps every in-range value within a byte without a clamp branch.
|
||||
/// </summary>
|
||||
private static byte[] Quantize(double[] profile)
|
||||
{
|
||||
var bytes = new byte[profile.Length];
|
||||
for (var i = 0; i < profile.Length; i++)
|
||||
{
|
||||
bytes[i] = (byte)(profile[i] * 255);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task EnsureVaultAsync()
|
||||
{
|
||||
if (!_fileDatabase.HasVault(VaultConstants.WaveformProfiles))
|
||||
{
|
||||
await _fileDatabase.CreateVaultAsync(VaultConstants.WaveformProfiles, MediaVaultType.Media);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,39 +12,43 @@ namespace DeepDrftContent;
|
||||
public class TrackContentService
|
||||
{
|
||||
private readonly FileDatabase.Services.FileDatabase _fileDatabase;
|
||||
private readonly AudioProcessor _audioProcessor;
|
||||
private readonly AudioProcessorRouter _audioProcessorRouter;
|
||||
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessor audioProcessor)
|
||||
public TrackContentService(FileDatabase.Services.FileDatabase fileDatabase, AudioProcessorRouter audioProcessorRouter)
|
||||
{
|
||||
_fileDatabase = fileDatabase;
|
||||
_audioProcessor = audioProcessor;
|
||||
_audioProcessorRouter = audioProcessorRouter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new track from a WAV file to both databases
|
||||
/// Adds a new track from a supported audio file (.wav, .mp3, .flac) to both databases. The
|
||||
/// router selects the processor by extension; original bytes are stored for mp3/flac (no
|
||||
/// transcoding), while EXTENSIBLE WAVs are normalized to standard PCM at storage time.
|
||||
/// </summary>
|
||||
/// <param name="wavFilePath">Path to the WAV file</param>
|
||||
/// <param name="audioFilePath">Path to the audio file</param>
|
||||
/// <param name="trackName">Name of the track</param>
|
||||
/// <param name="artist">Artist name</param>
|
||||
/// <param name="album">Optional album name</param>
|
||||
/// <param name="genre">Optional genre</param>
|
||||
/// <param name="releaseDate">Optional release date</param>
|
||||
/// <param name="originalFileName">Optional original browser filename captured at upload time</param>
|
||||
/// <returns>The track entity with generated ID and media path</returns>
|
||||
public async Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
public async Task<TrackEntity?> AddTrackAsync(
|
||||
string audioFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null)
|
||||
DateOnly? releaseDate = null,
|
||||
string? originalFileName = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process the WAV file
|
||||
var audioBinary = await _audioProcessor.ProcessWavFileAsync(wavFilePath);
|
||||
// Process the audio file (routed by extension)
|
||||
var audioBinary = await _audioProcessorRouter.ProcessAudioFileAsync(audioFilePath);
|
||||
if (audioBinary == null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to process WAV file");
|
||||
throw new InvalidOperationException("Failed to process audio file");
|
||||
}
|
||||
|
||||
// Generate a unique track ID
|
||||
@@ -63,26 +67,39 @@ public class TrackContentService
|
||||
throw new InvalidOperationException("Failed to store audio in FileDatabase");
|
||||
}
|
||||
|
||||
// Create the track entity for SQL database
|
||||
// Create the track entity for SQL database. Post Phase 8 §8.0 the entity holds only
|
||||
// track-cardinal fields; release-cardinal data (artist/album/genre/releaseDate) is
|
||||
// resolved into a ReleaseEntity by the caller (UnifiedTrackService) and linked via FK.
|
||||
var trackEntity = new TrackEntity
|
||||
{
|
||||
EntryKey = trackId, // FileDatabase entry ID
|
||||
TrackName = trackName,
|
||||
Artist = artist,
|
||||
Album = album,
|
||||
Genre = genre,
|
||||
ReleaseDate = releaseDate
|
||||
OriginalFileName = originalFileName
|
||||
};
|
||||
|
||||
return trackEntity;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"TrackContentService.AddTrackFromWavAsync failed: {ex.Message}");
|
||||
Console.WriteLine($"TrackContentService.AddTrackAsync failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backward-compatible shim — delegates to <see cref="AddTrackAsync"/>. The router accepts WAV
|
||||
/// alongside MP3 and FLAC, so this carries no WAV-specific logic of its own.
|
||||
/// </summary>
|
||||
public Task<TrackEntity?> AddTrackFromWavAsync(
|
||||
string wavFilePath,
|
||||
string trackName,
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? genre = null,
|
||||
DateOnly? releaseDate = null,
|
||||
string? originalFileName = null) =>
|
||||
AddTrackAsync(wavFilePath, trackName, artist, album, genre, releaseDate, originalFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves audio binary from FileDatabase
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using Data.Data.Configurations;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
public class ReleaseConfiguration : BaseEntityConfiguration<ReleaseEntity>
|
||||
{
|
||||
public override void Configure(EntityTypeBuilder<ReleaseEntity> builder)
|
||||
{
|
||||
// Wires up Id PK + audit columns (CreatedAt, UpdatedAt, IsDeleted) and the IsDeleted index.
|
||||
base.Configure(builder);
|
||||
|
||||
builder.ToTable("release");
|
||||
|
||||
// Map the base audit columns to the snake_case naming the rest of the schema uses.
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
builder.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
builder.Property(e => e.IsDeleted).HasColumnName("is_deleted");
|
||||
|
||||
builder.Property(e => e.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("title");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
|
||||
builder.Property(e => e.ReleaseType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store as readable string, not int ordinal
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("release_type")
|
||||
.HasDefaultValue(ReleaseType.Single);
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
// "IX_release_is_deleted" regardless of auto-naming conventions.
|
||||
builder.HasIndex(e => e.IsDeleted).HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
// Unique constraint on the natural key (title + artist). Prevents duplicate release rows
|
||||
// from concurrent uploads of the same album. The FindOrCreateRelease path catches the
|
||||
// resulting ClassifiedDbException (UniqueViolation) and re-queries for the winning row.
|
||||
builder.HasIndex(e => new { e.Title, e.Artist })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
}
|
||||
}
|
||||
@@ -30,28 +30,24 @@ public class TrackConfiguration : BaseEntityConfiguration<TrackEntity>
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("track_name");
|
||||
|
||||
builder.Property(e => e.Artist)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("artist");
|
||||
|
||||
builder.Property(e => e.Album)
|
||||
.HasMaxLength(200)
|
||||
.HasColumnName("album");
|
||||
|
||||
builder.Property(e => e.Genre)
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("genre");
|
||||
|
||||
builder.Property(e => e.ReleaseDate)
|
||||
.HasColumnName("release_date");
|
||||
|
||||
builder.Property(e => e.ImagePath)
|
||||
builder.Property(e => e.OriginalFileName)
|
||||
.HasMaxLength(500)
|
||||
.HasColumnName("image_path");
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
builder.Property(e => e.CreatedByUserId)
|
||||
.HasColumnName("created_by_user_id");
|
||||
builder.Property(e => e.TrackNumber)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_number")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
// Nullable FK to the release-cardinal row. SetNull on delete: removing a release leaves its
|
||||
// tracks intact as loose tracks rather than cascading them away.
|
||||
builder.HasOne(e => e.Release)
|
||||
.WithMany(r => r.Tracks)
|
||||
.HasForeignKey(e => e.ReleaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Names the is_deleted index explicitly. BaseEntityConfiguration.Configure already
|
||||
// calls HasIndex(e => e.IsDeleted); this adds HasDatabaseName so EF always uses
|
||||
|
||||
@@ -11,11 +11,13 @@ public class DeepDrftContext : DbContext
|
||||
}
|
||||
|
||||
public DbSet<TrackEntity> Tracks { get; set; }
|
||||
public DbSet<ReleaseEntity> Releases { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.ApplyConfiguration(new TrackConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,40 @@ namespace DeepDrftData;
|
||||
public interface ITrackService
|
||||
{
|
||||
Task<ResultContainer<TrackDto?>> GetById(long id);
|
||||
Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single track chosen uniformly at random, or null when the library is empty
|
||||
/// (a valid state, not a failure). Backs the public "Stream Now" instant-play feature.
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<List<TrackDto>>> GetAll();
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, CancellationToken cancellationToken = default);
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPaged(int pageNumber, int pageSize, string? sortColumn, bool sortDescending, TrackFilter? filter = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>All releases, title-ascending, each carrying its non-deleted track count.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
|
||||
Task<ResultContainer<TrackDto>> Update(TrackDto track);
|
||||
Task<Result> Delete(long id);
|
||||
|
||||
/// <summary>Soft-delete a release row by id. Idempotent — a missing or already-deleted row is a no-op.</summary>
|
||||
Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Count of non-deleted tracks on a release. Backs the delete-cascade decision: when a track
|
||||
/// delete leaves a release with zero live tracks, the release is soft-deleted too.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260607124422_AddOriginalFileName")]
|
||||
partial class AddOriginalFileName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOriginalFileName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "original_file_name",
|
||||
table: "track",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "original_file_name",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611005700_AddReleaseTypeAndTrackNumber")]
|
||||
partial class AddReleaseTypeAndTrackNumber
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseTypeAndTrackNumber : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "track_number",
|
||||
table: "track",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_type",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "track_number",
|
||||
table: "track");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611164537_NormalizeReleaseTrack")]
|
||||
partial class NormalizeReleaseTrack
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NormalizeReleaseTrack : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Create the release table.
|
||||
migrationBuilder.CreateTable(
|
||||
name: "release",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
artist = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
genre = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
release_date = table.Column<DateOnly>(type: "date", nullable: true),
|
||||
image_path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
release_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Single"),
|
||||
created_by_user_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_release", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_is_deleted",
|
||||
table: "release",
|
||||
column: "is_deleted");
|
||||
|
||||
// 2. Add the nullable FK column to track. A fresh column (not a rename of
|
||||
// created_by_user_id) so existing rows start with a null release until back-filled.
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "release_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 3. Data migration — must run after the release table exists and release_id is added,
|
||||
// and before the release-cardinal columns are dropped from track (the SELECT reads them).
|
||||
// Create one release row per distinct (album, artist) from existing tracks, carrying
|
||||
// the release-cardinal fields. Tracks with a null album remain release_id = null.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO release (title, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, created_at, updated_at, is_deleted)
|
||||
SELECT DISTINCT ON (album, artist)
|
||||
album, artist, genre, release_date, image_path, release_type,
|
||||
created_by_user_id, NOW(), NOW(), false
|
||||
FROM track
|
||||
WHERE album IS NOT NULL
|
||||
ORDER BY album, artist, id;
|
||||
");
|
||||
|
||||
// Back-fill the FK: match each track to the release created from its (album, artist).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET release_id = r.id
|
||||
FROM release r
|
||||
WHERE track.album = r.title
|
||||
AND track.artist = r.artist;
|
||||
");
|
||||
|
||||
// 4. Index + FK now that the column carries its back-filled values.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track",
|
||||
column: "release_id",
|
||||
principalTable: "release",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
// 5. Drop the now-migrated release-cardinal columns from track.
|
||||
migrationBuilder.DropColumn(name: "album", table: "track");
|
||||
migrationBuilder.DropColumn(name: "artist", table: "track");
|
||||
migrationBuilder.DropColumn(name: "genre", table: "track");
|
||||
migrationBuilder.DropColumn(name: "image_path", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_date", table: "track");
|
||||
migrationBuilder.DropColumn(name: "release_type", table: "track");
|
||||
migrationBuilder.DropColumn(name: "created_by_user_id", table: "track");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1. Re-add the track release-cardinal columns. artist is non-nullable with a default so
|
||||
// the add succeeds against existing rows before the back-fill repopulates it.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "album",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "artist",
|
||||
table: "track",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "genre",
|
||||
table: "track",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "image_path",
|
||||
table: "track",
|
||||
type: "character varying(500)",
|
||||
maxLength: 500,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "release_date",
|
||||
table: "track",
|
||||
type: "date",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "release_type",
|
||||
table: "track",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Single");
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "created_by_user_id",
|
||||
table: "track",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
// 2. Re-populate the track columns from the release join before the release table and FK go.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE track
|
||||
SET artist = r.artist,
|
||||
album = r.title,
|
||||
genre = r.genre,
|
||||
release_date = r.release_date,
|
||||
image_path = r.image_path,
|
||||
release_type = r.release_type,
|
||||
created_by_user_id = r.created_by_user_id
|
||||
FROM release r
|
||||
WHERE track.release_id = r.id;
|
||||
");
|
||||
|
||||
// 3. Drop the FK, index, the release_id column, and the release table.
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_track_release_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_track_release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "release_id",
|
||||
table: "track");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260611184732_AddReleaseUniqueTitleArtist")]
|
||||
partial class AddReleaseUniqueTitleArtist
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReleaseUniqueTitleArtist : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release",
|
||||
columns: new[] { "title", "artist" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_release_title_artist",
|
||||
table: "release");
|
||||
}
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260612000000_SoftDeleteOrphanedReleases")]
|
||||
partial class SoftDeleteOrphanedReleases
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Data-only migration: no schema change, snapshot unchanged.
|
||||
public partial class SoftDeleteOrphanedReleases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Backfill: soft-delete any live release whose tracks were all soft-deleted before the
|
||||
// delete-cascade in UnifiedTrackService existed. These show as 0-track rows in the albums
|
||||
// browser; this clears the pre-existing orphans the cascade now prevents going forward.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE release
|
||||
SET is_deleted = true,
|
||||
updated_at = now()
|
||||
WHERE id IN (
|
||||
SELECT r.id
|
||||
FROM release r
|
||||
WHERE r.is_deleted = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_deleted = false
|
||||
)
|
||||
);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("-- no-op: orphaned release soft-deletes are not rolled back");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -31,11 +31,6 @@ namespace DeepDrftData.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Album")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("album");
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -50,12 +45,6 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
@@ -76,12 +65,82 @@ namespace DeepDrftData.Migrations
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -91,8 +150,25 @@ namespace DeepDrftData.Migrations
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,211 @@
|
||||
using Data.Data.Repositories;
|
||||
using Data.Errors;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Models.Common;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
public class TrackRepository : Repository<DeepDrftContext, TrackEntity>
|
||||
{
|
||||
// The base Repository<> exposes Query (soft-delete-filtered IQueryable<TrackEntity>) but no
|
||||
// DbContext accessor, and release-cardinal queries need a second DbSet. Keep our own reference
|
||||
// to the injected context rather than reaching for a service locator — it is the same scoped
|
||||
// instance the base holds, so reads/writes stay in one unit of work.
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public TrackRepository(
|
||||
DeepDrftContext context,
|
||||
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
|
||||
IDbExceptionClassifier? classifier = null)
|
||||
: base(context, logger, classifier: classifier)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
// Override base GetByIdAsync to include the Release navigation. Without this, the base
|
||||
// Query has no .Include, so Release is null on every entity (no lazy-loading proxies).
|
||||
public override async Task<TrackEntity?> GetByIdAsync(long id)
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(e => e.Id == id);
|
||||
|
||||
// Override base GetAllAsync for the same reason — include Release so callers (e.g.
|
||||
// TrackManager.GetAll) receive fully-populated entities without a separate query.
|
||||
public override async Task<IEnumerable<TrackEntity>> GetAllAsync()
|
||||
=> await Query.Include(t => t.Release).ToListAsync();
|
||||
|
||||
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
|
||||
// uses Query (soft-delete filtered) rather than the raw DbSet. Includes Release so the
|
||||
// converter can project the release-cardinal fields.
|
||||
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
|
||||
=> await Query.Include(t => t.Release).FirstOrDefaultAsync(t => t.EntryKey == entryKey);
|
||||
|
||||
// Picks one track uniformly at random. Two round-trips (count, then a single offset row)
|
||||
// rather than ORDER BY random() so the database never sorts the whole table — the catalogue
|
||||
// is small today but this keeps the cost flat as it grows. Returns null when empty so the
|
||||
// service surfaces a valid empty-library state, not an error. Uses Query (soft-delete
|
||||
// filtered) so deleted tracks are never candidates.
|
||||
public async Task<TrackEntity?> GetRandomAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = await Query.CountAsync(cancellationToken);
|
||||
if (count == 0)
|
||||
return null;
|
||||
|
||||
var index = Random.Shared.Next(count);
|
||||
return await Query
|
||||
.Include(t => t.Release)
|
||||
.OrderBy(t => t.Id)
|
||||
.Skip(index)
|
||||
.Take(1)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Paged query with optional filter predicates. Built off Query (soft-delete filtered) rather than the
|
||||
// base GetPagedAsync(paging) overload, which takes no where-clause. The OrderBy expression and
|
||||
// direction ride in on the PagingParameters the manager already built, so sort + filter +
|
||||
// pagination compose. Filter predicates apply before sort and Skip/Take so TotalCount reflects
|
||||
// the filtered set.
|
||||
public async Task<PagedResult<TrackEntity>> GetPagedFilteredAsync(
|
||||
PagingParameters<TrackEntity> paging,
|
||||
TrackFilter? filter,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Include Release so both the filter predicates and the converter can read release-cardinal
|
||||
// fields through the navigation.
|
||||
IQueryable<TrackEntity> query = Query.Include(t => t.Release);
|
||||
|
||||
if (filter is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filter.SearchText))
|
||||
{
|
||||
// Postgres case-insensitive LIKE. The '%' wraps make it a contains-match; ILike is
|
||||
// EF-translatable where ToLower().Contains() is not. Artist/Title live on the joined
|
||||
// Release, which is null for loose tracks — guard the navigation before ILike.
|
||||
var pattern = $"%{filter.SearchText}%";
|
||||
query = query.Where(t =>
|
||||
EF.Functions.ILike(t.TrackName, pattern)
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Artist, pattern))
|
||||
|| (t.Release != null && EF.Functions.ILike(t.Release.Title, pattern)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Album))
|
||||
query = query.Where(t => t.Release != null && t.Release.Title == filter.Album);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Genre))
|
||||
query = query.Where(t => t.Release != null && t.Release.Genre == filter.Genre);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync(ct);
|
||||
|
||||
if (paging.OrderBy is not null)
|
||||
{
|
||||
query = paging.IsDescending
|
||||
? query.OrderByDescending(paging.OrderBy)
|
||||
: query.OrderBy(paging.OrderBy);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.Skip(paging.Skip)
|
||||
.Take(paging.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return new PagedResult<TrackEntity>
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = totalCount,
|
||||
Page = paging.Page,
|
||||
PageSize = paging.PageSize,
|
||||
};
|
||||
}
|
||||
|
||||
// All non-deleted releases, title-ascending, each carrying its count of non-deleted tracks.
|
||||
// The TrackCount subquery keeps this a single round-trip; the manager projects to ReleaseDto.
|
||||
public async Task<List<ReleaseEntity>> GetReleasesAsync(CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.Where(r => !r.IsDeleted)
|
||||
.OrderBy(r => r.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Distinct genres (non-null) with track counts, sourced from the release join. Counting tracks
|
||||
// (not releases) keeps the browse counts consistent with the track-level catalogue. Loose tracks
|
||||
// (no release) carry no genre and are excluded.
|
||||
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.Release != null && t.Release.Genre != null)
|
||||
.GroupBy(t => t.Release!.Genre!)
|
||||
.Select(g => new GenreSummaryDto
|
||||
{
|
||||
Genre = g.Key,
|
||||
TrackCount = g.Count(),
|
||||
})
|
||||
.OrderBy(g => g.Genre)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Count of non-deleted tracks per release, keyed by ReleaseId. The manager joins this against
|
||||
// GetReleasesAsync to populate ReleaseDto.TrackCount without an N+1 fan-out.
|
||||
public async Task<Dictionary<long, int>> GetTrackCountsByReleaseAsync(CancellationToken ct = default)
|
||||
=> await Query
|
||||
.Where(t => t.ReleaseId != null)
|
||||
.GroupBy(t => t.ReleaseId!.Value)
|
||||
.Select(g => new { ReleaseId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.ReleaseId, x => x.Count, ct);
|
||||
|
||||
// Resolve an existing release by its natural key (title + artist). Returns null when no match,
|
||||
// signalling the manager to create one. Soft-deleted releases never match.
|
||||
public async Task<ReleaseEntity?> GetReleaseByTitleAndArtistAsync(
|
||||
string title, string artist, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Title == title && r.Artist == artist && !r.IsDeleted, ct);
|
||||
|
||||
// Persist a new release row and return it with its assigned Id. Lives here (not the manager)
|
||||
// because the repository owns the DbContext — the manager stays free of direct context access.
|
||||
public async Task<ReleaseEntity> AddReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Add(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
return release;
|
||||
}
|
||||
|
||||
// Load a tracked release by id so the manager can edit its fields in place and save. Returns
|
||||
// null when the id does not resolve (or the release is soft-deleted).
|
||||
public async Task<ReleaseEntity?> GetReleaseByIdAsync(long id, CancellationToken ct = default)
|
||||
=> await _context.Set<ReleaseEntity>()
|
||||
.FirstOrDefaultAsync(r => r.Id == id && !r.IsDeleted, ct);
|
||||
|
||||
// Persist edits to a release. Update marks the whole entity modified, so it works whether the
|
||||
// instance is the change-tracked one from GetReleaseByIdAsync or a detached graph.
|
||||
public async Task UpdateReleaseAsync(ReleaseEntity release, CancellationToken ct = default)
|
||||
{
|
||||
_context.Set<ReleaseEntity>().Update(release);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Soft-delete a release row in a single set-based UPDATE (no load round-trip). The !IsDeleted
|
||||
// guard makes a repeat call a no-op rather than re-stamping updated_at on an already-deleted row.
|
||||
public async Task SoftDeleteReleaseAsync(long id, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Set<ReleaseEntity>()
|
||||
.Where(r => r.Id == id && !r.IsDeleted)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(r => r.IsDeleted, true)
|
||||
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
|
||||
// Count of non-deleted tracks on a single release. Backs the delete-cascade decision in
|
||||
// UnifiedTrackService: a release with zero live tracks after a delete is soft-deleted too.
|
||||
// Uses Query (soft-delete filtered) so just-deleted tracks are excluded from the count.
|
||||
public async Task<int> CountLiveTracksByReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
=> await Query.CountAsync(t => t.ReleaseId == releaseId, ct);
|
||||
|
||||
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
|
||||
{
|
||||
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
|
||||
target.EntryKey = source.EntryKey;
|
||||
target.TrackName = source.TrackName;
|
||||
target.Artist = source.Artist;
|
||||
target.Album = source.Album;
|
||||
target.Genre = source.Genre;
|
||||
target.ReleaseDate = source.ReleaseDate;
|
||||
target.ImagePath = source.ImagePath;
|
||||
target.CreatedByUserId = source.CreatedByUserId;
|
||||
target.TrackNumber = source.TrackNumber;
|
||||
target.OriginalFileName = source.OriginalFileName;
|
||||
target.ReleaseId = source.ReleaseId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,40 @@ namespace DeepDrftData;
|
||||
/// The DTO side mirrors the entity field-for-field; the audit columns
|
||||
/// (CreatedAt, UpdatedAt) come from BaseEntity / BaseModel.
|
||||
/// IsDeleted does not round-trip — soft-deleted rows are not exposed via the model.
|
||||
///
|
||||
/// Post Phase 8 §8.0: TrackEntity carries only track-cardinal fields plus a nullable
|
||||
/// ReleaseId/Release. The release-cardinal data converts through the Release maps below.
|
||||
/// </summary>
|
||||
public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
{
|
||||
public static ReleaseDto Convert(ReleaseEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
Title = entity.Title,
|
||||
Artist = entity.Artist,
|
||||
Genre = entity.Genre,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
ReleaseType = entity.ReleaseType,
|
||||
CreatedByUserId = entity.CreatedByUserId
|
||||
};
|
||||
|
||||
public static ReleaseEntity Convert(ReleaseDto dto) => new()
|
||||
{
|
||||
Id = dto.Id,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
UpdatedAt = dto.UpdatedAt,
|
||||
Title = dto.Title,
|
||||
Artist = dto.Artist,
|
||||
Genre = dto.Genre,
|
||||
ReleaseDate = dto.ReleaseDate,
|
||||
ImagePath = dto.ImagePath,
|
||||
ReleaseType = dto.ReleaseType,
|
||||
CreatedByUserId = dto.CreatedByUserId
|
||||
};
|
||||
|
||||
public static TrackDto Convert(TrackEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
@@ -19,14 +50,15 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
EntryKey = entity.EntryKey,
|
||||
TrackName = entity.TrackName,
|
||||
Artist = entity.Artist,
|
||||
Album = entity.Album,
|
||||
Genre = entity.Genre,
|
||||
ReleaseDate = entity.ReleaseDate,
|
||||
ImagePath = entity.ImagePath,
|
||||
CreatedByUserId = entity.CreatedByUserId
|
||||
OriginalFileName = entity.OriginalFileName,
|
||||
TrackNumber = entity.TrackNumber,
|
||||
ReleaseId = entity.ReleaseId,
|
||||
Release = entity.Release is null ? null : Convert(entity.Release)
|
||||
};
|
||||
|
||||
// DTO → entity maps track-cardinal fields + ReleaseId only. The Release navigation is left
|
||||
// unset: the manager resolves/attaches the release row against the tracked context so a detached
|
||||
// graph never overwrites a shared release record.
|
||||
public static TrackEntity Convert(TrackDto model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
@@ -34,11 +66,8 @@ public class TrackConverter : IEntityToModelConverter<TrackEntity, TrackDto>
|
||||
UpdatedAt = model.UpdatedAt,
|
||||
EntryKey = model.EntryKey,
|
||||
TrackName = model.TrackName,
|
||||
Artist = model.Artist,
|
||||
Album = model.Album,
|
||||
Genre = model.Genre,
|
||||
ReleaseDate = model.ReleaseDate,
|
||||
ImagePath = model.ImagePath,
|
||||
CreatedByUserId = model.CreatedByUserId
|
||||
OriginalFileName = model.OriginalFileName,
|
||||
TrackNumber = model.TrackNumber,
|
||||
ReleaseId = model.ReleaseId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Data.Errors;
|
||||
using Data.Managers;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
@@ -46,6 +47,38 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup by vault entry key. No base-name conflict (unlike GetById), so this is a plain
|
||||
// public method. Mirrors the nullable-on-miss shape of ITrackService.GetById.
|
||||
public async Task<ResultContainer<TrackDto?>> GetByEntryKey(string entryKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await Repository.GetByEntryKeyAsync(entryKey);
|
||||
return ResultContainer<TrackDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// No base-name conflict, so this is a plain public method. Mirrors the nullable-on-empty
|
||||
// shape of GetById: pass with null when the library has no tracks.
|
||||
public async Task<ResultContainer<TrackDto?>> GetRandom(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await Repository.GetRandomAsync(cancellationToken);
|
||||
return ResultContainer<TrackDto?>.CreatePassResult(
|
||||
entity is null ? null : TrackConverter.Convert(entity));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<TrackDto?>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<TrackDto>>> GetAll()
|
||||
{
|
||||
try
|
||||
@@ -65,6 +98,7 @@ public class TrackManager
|
||||
int pageSize,
|
||||
string? sortColumn,
|
||||
bool sortDescending,
|
||||
TrackFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -74,18 +108,28 @@ public class TrackManager
|
||||
Page = pageNumber,
|
||||
PageSize = pageSize,
|
||||
IsDescending = sortDescending,
|
||||
// Sorts navigate through the nullable Release relation; the null-coalescing
|
||||
// sentinels push loose tracks (no release) to the end, matching the prior
|
||||
// nulls-last behaviour on the flat columns.
|
||||
OrderBy = sortColumn switch
|
||||
{
|
||||
"TrackName" => e => e.TrackName,
|
||||
"Artist" => e => e.Artist,
|
||||
"Album" => e => (object)(e.Album ?? string.Empty),
|
||||
"Genre" => e => (object)(e.Genre ?? string.Empty),
|
||||
"ReleaseDate" => e => (object)(e.ReleaseDate ?? DateOnly.MaxValue),
|
||||
_ => e => e.Id
|
||||
"Artist" => e => (object)(e.Release == null ? string.Empty : e.Release.Artist),
|
||||
"Album" => e => (object)(e.Release == null ? string.Empty : e.Release.Title),
|
||||
"Genre" => e => (object)(e.Release == null ? string.Empty : (e.Release.Genre ?? string.Empty)),
|
||||
"ReleaseDate" => e => (object)(e.Release == null ? DateOnly.MaxValue : (e.Release.ReleaseDate ?? DateOnly.MaxValue)),
|
||||
"TrackNumber" => e => e.TrackNumber,
|
||||
_ => e => e.Id
|
||||
}
|
||||
};
|
||||
|
||||
var page = await Repository.GetPagedAsync(parameters);
|
||||
// Always route through GetPagedFilteredAsync — it handles a null filter by skipping
|
||||
// all Where predicates, and it always includes Release. This removes the base-class
|
||||
// GetPagedAsync path, which has no .Include and would return entities with null Release.
|
||||
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
|
||||
|
||||
var page = await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
|
||||
|
||||
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
|
||||
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
|
||||
}
|
||||
@@ -95,10 +139,100 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleases(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var releases = await Repository.GetReleasesAsync(cancellationToken);
|
||||
var counts = await Repository.GetTrackCountsByReleaseAsync(cancellationToken);
|
||||
|
||||
var dtos = releases
|
||||
.Select(r =>
|
||||
{
|
||||
var dto = TrackConverter.Convert(r);
|
||||
dto.TrackCount = counts.GetValueOrDefault(r.Id);
|
||||
return dto;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return ResultContainer<List<ReleaseDto>>.CreatePassResult(dtos);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is not null)
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||
|
||||
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
var entity = TrackConverter.Convert(releaseData);
|
||||
entity.Id = 0;
|
||||
entity.Title = title;
|
||||
entity.Artist = artist;
|
||||
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||
{
|
||||
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||
// Re-query and return the winning row. Should not return null here since the
|
||||
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (race is null) throw;
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var genres = await Repository.GetDistinctGenresAsync(cancellationToken);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<TrackDto>> Create(TrackDto newTrack)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A track with release context resolves (or creates) the shared release first so the FK
|
||||
// is set before insert. A standalone track (Release null) stays a loose track, ReleaseId
|
||||
// null. Callers that already resolved the FK (UnifiedTrackService) pass Release null and
|
||||
// a populated ReleaseId, which falls straight through.
|
||||
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||
{
|
||||
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||
if (!resolved.Success || resolved.Value is null)
|
||||
{
|
||||
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
newTrack.ReleaseId = resolved.Value.Id;
|
||||
}
|
||||
|
||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||
return ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
}
|
||||
@@ -115,6 +249,26 @@ public class TrackManager
|
||||
try
|
||||
{
|
||||
await Repository.UpdateAsync(TrackConverter.Convert(track));
|
||||
|
||||
// Release-cardinal edits flow through the linked release row, not the track. When the
|
||||
// track carries a Release payload and a resolved FK, load the tracked release, apply the
|
||||
// edited fields, and save. EntryKey/track fields are already persisted above.
|
||||
if (track.Release is { } release && track.ReleaseId is { } releaseId)
|
||||
{
|
||||
var releaseEntity = await Repository.GetReleaseByIdAsync(releaseId);
|
||||
if (releaseEntity is not null)
|
||||
{
|
||||
releaseEntity.Title = release.Title;
|
||||
releaseEntity.Artist = release.Artist;
|
||||
releaseEntity.Genre = release.Genre;
|
||||
releaseEntity.ReleaseDate = release.ReleaseDate;
|
||||
releaseEntity.ImagePath = release.ImagePath;
|
||||
releaseEntity.ReleaseType = release.ReleaseType;
|
||||
releaseEntity.CreatedByUserId = release.CreatedByUserId;
|
||||
await Repository.UpdateReleaseAsync(releaseEntity);
|
||||
}
|
||||
}
|
||||
|
||||
var updated = await Repository.GetByIdAsync(track.Id);
|
||||
return updated is not null
|
||||
? ResultContainer<TrackDto>.CreatePassResult(TrackConverter.Convert(updated))
|
||||
@@ -128,4 +282,30 @@ public class TrackManager
|
||||
|
||||
// Delete(long) → Result is inherited from Manager<> and satisfies ITrackService.Delete
|
||||
// by signature. No override.
|
||||
|
||||
public async Task<Result> DeleteRelease(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Repository.SoftDeleteReleaseAsync(id, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> CountLiveTracksByRelease(long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await Repository.CountLiveTracksByReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<DeepDrftFontLinks />
|
||||
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
|
||||
<link rel="stylesheet" href="@Assets["DeepDrftManager.styles.css"]" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="@Assets["_content/DeepDrftShared.Client/styles/deepdrft-tokens.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/ico" href="deepdrft-logo.ico" />
|
||||
|
||||
@@ -1,11 +1,125 @@
|
||||
@page "/"
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
@layout Layout.CmsLayout
|
||||
|
||||
@inject NavigationManager Nav
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject ILogger<Index> Logger
|
||||
|
||||
<PageTitle>DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h3" GutterBottom="true">DeepDrft CMS</MudText>
|
||||
<MudText Typo="Typo.body1">Administration panel — under construction.</MudText>
|
||||
<MudText Typo="Typo.h3" Class="mb-6">Catalogue</MudText>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Tracks", Icons.Material.Filled.LibraryMusic, Color.Primary, _tracksLoading, _trackCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Releases", Icons.Material.Filled.Album, Color.Secondary, _albumsLoading, _albumCount)
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
@SummaryCard("Genres", Icons.Material.Filled.Category, Color.Tertiary, _genresLoading, _genreCount)
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private bool _tracksLoading = true;
|
||||
private bool _albumsLoading = true;
|
||||
private bool _genresLoading = true;
|
||||
|
||||
private int? _trackCount;
|
||||
private int? _albumCount;
|
||||
private int? _genreCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Three independent reads run concurrently. Each loader calls StateHasChanged in its
|
||||
// finally block so its card updates as soon as its own fetch returns.
|
||||
await Task.WhenAll(LoadTrackCount(), LoadAlbumCount(), LoadGenreCount());
|
||||
}
|
||||
|
||||
private async Task LoadTrackCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetTrackCountAsync();
|
||||
_trackCount = result.Success ? result.Value : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard track count failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tracksLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAlbumCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetReleasesAsync();
|
||||
_albumCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard album summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_albumsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadGenreCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GetGenreSummariesAsync();
|
||||
_genreCount = result.Success && result.Value is not null ? result.Value.Count : null;
|
||||
if (!result.Success)
|
||||
{
|
||||
Logger.LogWarning("Dashboard genre summaries failed: {Error}",
|
||||
result.Messages.FirstOrDefault()?.Message ?? "Unknown error");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_genresLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment SummaryCard(string label, string icon, Color color, bool loading, int? count) => __builder =>
|
||||
{
|
||||
<MudCard Elevation="8" Style="height: 100%;">
|
||||
<MudCardContent>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2" Class="py-4">
|
||||
<MudIcon Icon="@icon" Color="@color" Size="Size.Large" />
|
||||
@if (loading)
|
||||
{
|
||||
<MudProgressCircular Color="@color" Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h3" Color="@color">@(count?.ToString() ?? "—")</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-secondary text-uppercase">@label</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="justify-center pb-4">
|
||||
<MudButton Variant="Variant.Text" Color="@color" EndIcon="@Icons.Material.Filled.ArrowForward"
|
||||
OnClick="@(() => Nav.NavigateTo("/tracks"))">
|
||||
View
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
|
||||
<MudPaper Class="pa-6 mb-4" Elevation="2">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="AlbumName" ValueChanged="@((string v) => AlbumNameChanged.InvokeAsync(v))"
|
||||
T="string" Label="Album Name" Required="true" RequiredError="Album Name is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Artist" ValueChanged="@((string v) => ArtistChanged.InvokeAsync(v))"
|
||||
T="string" Label="Artist" Required="true" RequiredError="Artist is required"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="Genre" ValueChanged="@((string v) => GenreChanged.InvokeAsync(v))"
|
||||
T="string" Label="Genre" Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudTextField Value="ReleaseDate" ValueChanged="@((string v) => ReleaseDateChanged.InvokeAsync(v))"
|
||||
T="string" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15"
|
||||
Variant="Variant.Outlined" Disabled="Disabled" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudSelect T="ReleaseType" Value="ReleaseType" ValueChanged="@((ReleaseType v) => ReleaseTypeChanged.InvokeAsync(v))"
|
||||
Label="Release Type" Variant="Variant.Outlined" Disabled="Disabled">
|
||||
@foreach (var rt in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem T="ReleaseType" Value="rt">@rt</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (SelectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="Disabled"
|
||||
OnClick="ClearSelectedFile"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (ExistingImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Current cover art"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Current cover art.</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@Disabled" />
|
||||
@if (SelectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on submit.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> AlbumNameChanged { get; set; }
|
||||
[Parameter] public string Artist { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ArtistChanged { get; set; }
|
||||
[Parameter] public string Genre { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> GenreChanged { get; set; }
|
||||
[Parameter] public string ReleaseDate { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ReleaseDateChanged { get; set; }
|
||||
[Parameter] public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
[Parameter] public EventCallback<ReleaseType> ReleaseTypeChanged { get; set; }
|
||||
[Parameter] public IBrowserFile? SelectedImageFile { get; set; }
|
||||
[Parameter] public EventCallback<IBrowserFile?> SelectedImageFileChanged { get; set; }
|
||||
|
||||
// BatchEdit only: when set (and no new file picked), preview the release's current cover.
|
||||
// The parent nulls this to drop the preview when the admin clears the existing cover.
|
||||
[Parameter] public string? ExistingImagePath { get; set; }
|
||||
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser hits
|
||||
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses.
|
||||
private string? ExistingImagePreviewUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(ExistingImagePath)) return null;
|
||||
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
return baseAddress is null
|
||||
? null
|
||||
: new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(ExistingImagePath)}").ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleImageFileSelected(InputFileChangeEventArgs e) =>
|
||||
SelectedImageFileChanged.InvokeAsync(e.File);
|
||||
|
||||
private Task ClearSelectedFile() =>
|
||||
SelectedImageFileChanged.InvokeAsync(null);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
@page "/tracks/album/{AlbumName}/edit"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject ILogger<BatchEdit> Logger
|
||||
|
||||
<PageTitle>Edit Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Edit Release</MudText>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_loadError is { } loadError)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mt-4">@loadError</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
ExistingImagePath="_existingImagePath"
|
||||
Disabled="_saving" />
|
||||
|
||||
@if (_existingImagePath is not null && _selectedImageFile is null)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Delete"
|
||||
Disabled="_saving"
|
||||
OnClick="RemoveCover">
|
||||
Remove cover
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_saving"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_saving"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks/albums"))"
|
||||
Disabled="_saving">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SaveAsync"
|
||||
Disabled="@(_saving || _tracks.Count == 0)">
|
||||
@if (_saving)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Saving @_processedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Save Changes</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _loading = true;
|
||||
private string? _loadError;
|
||||
private bool _saving;
|
||||
private int _processedCount;
|
||||
private string? _errorMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string? _existingImagePath;
|
||||
private bool _clearExistingImage;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// A single page of 100 covers the full release (albums are small — same assumption as
|
||||
// CmsAlbumBrowser). Sorted by track number so list order matches the saved ordinals.
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: AlbumName);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
_loadError = result.Messages.FirstOrDefault()?.Message ?? "Failed to load release.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var tracks = result.Value.Items.ToList();
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_loadError = $"No tracks found for release '{AlbumName}'.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
_albumName = AlbumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
_releaseDate = release?.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty;
|
||||
_releaseType = release?.ReleaseType ?? ReleaseType.Single;
|
||||
_existingImagePath = release?.ImagePath;
|
||||
|
||||
_tracks = tracks.Select(t => new BatchRowModel
|
||||
{
|
||||
Id = t.Id,
|
||||
EntryKey = t.EntryKey,
|
||||
OriginalFileName = t.OriginalFileName,
|
||||
TrackName = t.TrackName,
|
||||
TrackNumber = t.TrackNumber,
|
||||
WavFile = null,
|
||||
Status = BatchRowStatus.Queued
|
||||
}).ToList();
|
||||
|
||||
_selectedIndex = _tracks.Count > 0 ? 0 : -1;
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New rows carry no Id — they take the upload path on save.
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private async Task RemoveRow(int index)
|
||||
{
|
||||
var row = _tracks[index];
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — confirm before deleting.
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Remove track",
|
||||
$"Remove '{row.TrackName}' from this release? This deletes the track permanently.",
|
||||
yesText: "Remove", cancelText: "Cancel");
|
||||
if (confirmed != true) return;
|
||||
|
||||
var result = await CmsTrackService.DeleteTrackAsync(row.Id.Value);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New track (not yet uploaded) or confirmed existing delete — remove from list.
|
||||
_tracks.RemoveAt(index);
|
||||
if (index < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private void RemoveCover()
|
||||
{
|
||||
// Defer the actual clear to save: pass "" to UpdateAsync's tri-state imagePath. Nulling
|
||||
// the existing path here drops the preview in AlbumHeaderFields.
|
||||
_clearExistingImage = true;
|
||||
_existingImagePath = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Album Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "A release must have at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
// New rows (no Id) need a WAV; existing rows keep their vault audio.
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (!t.Id.HasValue && t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// [Authorize]/Admin-gated page — an unparseable id here is a configuration bug.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
DateOnly? releaseDate = string.IsNullOrWhiteSpace(_releaseDate)
|
||||
? null
|
||||
: DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd");
|
||||
var album = string.IsNullOrWhiteSpace(_albumName) ? null : _albumName;
|
||||
var genre = string.IsNullOrWhiteSpace(_genre) ? null : _genre;
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_saving = true;
|
||||
_processedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any newly picked cover art once; abort if it fails so we never point metadata
|
||||
// at an image that was never stored.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// Tri-state cover for UpdateAsync: a freshly uploaded path sets it; an explicit clear
|
||||
// sends ""; otherwise null leaves the existing cover untouched.
|
||||
string? imagePathForUpdate =
|
||||
_imagePath is { } newPath ? newPath
|
||||
: _clearExistingImage ? ""
|
||||
: null;
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
|
||||
if (row.Status == BatchRowStatus.Done)
|
||||
{
|
||||
_processedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
if (row.Id.HasValue)
|
||||
{
|
||||
// Existing track — metadata-only update; audio stays in the vault.
|
||||
var updateResult = await CmsTrackService.UpdateAsync(
|
||||
row.Id.Value,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
imagePathForUpdate,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!updateResult.Success)
|
||||
{
|
||||
var error = updateResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: update for '{TrackName}' (id={Id}) failed: {Error}",
|
||||
row.TrackName, row.Id.Value, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New track — upload, then link cover art with a follow-up update (same
|
||||
// two-step pattern as BatchUpload; the upload endpoint takes no imagePath).
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
var uploadResult = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
{
|
||||
var error = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch edit: upload for new track '{TrackName}' failed: {Error}",
|
||||
row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Link a cover only when one is actively set ("" clear doesn't apply to
|
||||
// a brand-new track that has no cover yet).
|
||||
if (imagePathForUpdate is { Length: > 0 } linkPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
uploadResult.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
linkPath,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track persisted; cover can be linked via TrackEdit.
|
||||
Logger.LogWarning("Batch edit: cover link failed for new track '{TrackName}' (id={Id})",
|
||||
row.TrackName, uploadResult.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch edit: exception processing '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Save failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_processedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Either branch changed catalogue data, so the browse caches are stale regardless of
|
||||
// whether every track saved. Invalidate before navigating (or staying) so the /tracks
|
||||
// album and genre lists re-fetch.
|
||||
VM.Invalidate();
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s).", Severity.Success);
|
||||
Navigation.NavigateTo("/tracks/albums");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Saved {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace DeepDrftManager.Components.Pages.Tracks;
|
||||
|
||||
/// <summary>
|
||||
/// A single track row shared by <c>BatchUpload</c> (all rows are new uploads) and
|
||||
/// <c>BatchEdit</c> (existing rows carry <see cref="Id"/>; admins may also add new upload rows).
|
||||
/// </summary>
|
||||
public class BatchRowModel
|
||||
{
|
||||
/// <summary>SQL id of an existing track. <c>null</c> means a new row to upload.</summary>
|
||||
public long? Id { get; set; }
|
||||
|
||||
/// <summary>Vault entry key — existing rows only.</summary>
|
||||
public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Original upload filename — existing rows only, read-only display.</summary>
|
||||
public string? OriginalFileName { get; set; }
|
||||
|
||||
/// <summary>Selected WAV — new rows only.</summary>
|
||||
public IBrowserFile? WavFile { get; set; }
|
||||
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
|
||||
public int TrackNumber { get; set; }
|
||||
|
||||
public BatchRowStatus Status { get; set; } = BatchRowStatus.Queued;
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum BatchRowStatus { Queued, Uploading, Done, Failed }
|
||||
@@ -0,0 +1,60 @@
|
||||
@if (SelectedTrack is null)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Default">Select a track from the list to edit its details.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
<MudTextField Value="SelectedTrack.TrackName"
|
||||
ValueChanged="@((string v) => TrackNameChanged.InvokeAsync(v))"
|
||||
T="string"
|
||||
Label="Track Name"
|
||||
Required="true"
|
||||
RequiredError="Track Name is required"
|
||||
Variant="Variant.Outlined"
|
||||
Disabled="Disabled" />
|
||||
|
||||
@if (SelectedTrack.Id.HasValue)
|
||||
{
|
||||
<MudField Label="Original File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudText Typo="Typo.body2">@(string.IsNullOrEmpty(SelectedTrack.OriginalFileName) ? "—" : SelectedTrack.OriginalFileName)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Default">Existing track — audio is not editable.</MudText>
|
||||
</MudField>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudField Label="WAV File" Variant="Variant.Outlined" InnerPadding="false">
|
||||
@if (SelectedTrack.WavFile is { } wav)
|
||||
{
|
||||
<MudText Typo="Typo.body2">@wav.Name (@FormatBytes(wav.Size))</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Error">No WAV file selected.</MudText>
|
||||
}
|
||||
</MudField>
|
||||
}
|
||||
|
||||
@if (SelectedTrack.Status == BatchRowStatus.Failed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error">@SelectedTrack.ErrorMessage</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public BatchRowModel? SelectedTrack { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public EventCallback<string> TrackNameChanged { get; set; }
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = KB * 1024;
|
||||
const long GB = MB * 1024;
|
||||
if (bytes >= GB) return $"{bytes / (double)GB:F2} GB";
|
||||
if (bytes >= MB) return $"{bytes / (double)MB:F2} MB";
|
||||
if (bytes >= KB) return $"{bytes / (double)KB:F2} KB";
|
||||
return $"{bytes} bytes";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" GutterBottom="true">Tracks</MudText>
|
||||
|
||||
@if (AllowNewTracks)
|
||||
{
|
||||
<InputFile OnChange="HandleWavFilesSelected" accept=".wav,audio/wav,audio/x-wav" multiple disabled="@Disabled" />
|
||||
}
|
||||
|
||||
@if (Tracks.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default" Class="mt-3">No tracks added yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="BatchRowModel" Class="mt-3">
|
||||
@for (var i = 0; i < Tracks.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
var row = Tracks[index];
|
||||
<div style="@RowStyle(index)" @onclick="() => SelectRow(index)">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="pa-2">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 1.5rem;">@(index + 1).</MudText>
|
||||
<MudText Typo="Typo.body2" Style="flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@row.TrackName</MudText>
|
||||
@StatusChip(row)
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == 0 || Disabled)"
|
||||
OnClick="@(() => OnMoveUp.InvokeAsync(index))"
|
||||
aria-label="Move track up" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
Size="Size.Small"
|
||||
Disabled="@(index == Tracks.Count - 1 || Disabled)"
|
||||
OnClick="@(() => OnMoveDown.InvokeAsync(index))"
|
||||
aria-label="Move track down" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="@Disabled"
|
||||
OnClick="@(() => OnRemove.InvokeAsync(index))"
|
||||
aria-label="Remove track" />
|
||||
</MudStack>
|
||||
</div>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<BatchRowModel> Tracks { get; set; } = new();
|
||||
[Parameter] public int SelectedIndex { get; set; }
|
||||
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNewTracks { get; set; } = true;
|
||||
[Parameter] public EventCallback<IReadOnlyList<IBrowserFile>> OnWavFilesSelected { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveUp { get; set; }
|
||||
[Parameter] public EventCallback<int> OnMoveDown { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
private const int MaxFilesPerPick = 50;
|
||||
|
||||
private Task SelectRow(int index) => SelectedIndexChanged.InvokeAsync(index);
|
||||
|
||||
private Task HandleWavFilesSelected(InputFileChangeEventArgs e) =>
|
||||
OnWavFilesSelected.InvokeAsync(e.GetMultipleFiles(MaxFilesPerPick));
|
||||
|
||||
private string RowStyle(int index)
|
||||
{
|
||||
const string baseStyle = "cursor: pointer; border-radius: 4px;";
|
||||
return index == SelectedIndex
|
||||
? $"{baseStyle} background-color: var(--mud-palette-action-default-hover);"
|
||||
: baseStyle;
|
||||
}
|
||||
|
||||
private RenderFragment StatusChip(BatchRowModel row) => row.Status switch
|
||||
{
|
||||
BatchRowStatus.Uploading => @<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Text">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-1" />Uploading</MudChip>,
|
||||
BatchRowStatus.Done => @<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Text" Icon="@Icons.Material.Filled.CheckCircle">Done</MudChip>,
|
||||
BatchRowStatus.Failed => @<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text" Icon="@Icons.Material.Filled.Error">Failed</MudChip>,
|
||||
_ => @<MudChip T="string" Size="Size.Small" Color="Color.Default" Variant="Variant.Text">Queued</MudChip>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
@page "/tracks/upload"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<BatchUpload> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Upload Release — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Upload Release</MudText>
|
||||
|
||||
<AlbumHeaderFields @bind-AlbumName="_albumName"
|
||||
@bind-Artist="_artist"
|
||||
@bind-Genre="_genre"
|
||||
@bind-ReleaseDate="_releaseDate"
|
||||
@bind-ReleaseType="_releaseType"
|
||||
@bind-SelectedImageFile="_selectedImageFile"
|
||||
Disabled="_uploading" />
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="5">
|
||||
<BatchTrackList Tracks="_tracks"
|
||||
@bind-SelectedIndex="_selectedIndex"
|
||||
Disabled="_uploading"
|
||||
OnWavFilesSelected="HandleWavFilesSelected"
|
||||
OnMoveUp="MoveUp"
|
||||
OnMoveDown="MoveDown"
|
||||
OnRemove="RemoveRow" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<BatchTrackDetail SelectedTrack="@(_selectedIndex >= 0 && _tracks.Count > 0 ? _tracks[_selectedIndex] : null)"
|
||||
Disabled="_uploading"
|
||||
TrackNameChanged="@(name => { if (_selectedIndex >= 0) { _tracks[_selectedIndex].TrackName = name; } })" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mt-4">@_errorMessage</MudAlert>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Spacing="2" Class="mt-4">
|
||||
<MudButton Variant="Variant.Text"
|
||||
OnClick="@(() => Navigation.NavigateTo("/tracks"))"
|
||||
Disabled="_uploading">
|
||||
Cancel
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="SubmitAsync"
|
||||
Disabled="@(_uploading || _tracks.Count == 0)">
|
||||
@if (_uploading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="mr-2" />
|
||||
<text>Uploading @_uploadedCount / @_tracks.Count…</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>Upload Release</text>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _uploading;
|
||||
private int _uploadedCount;
|
||||
private string? _errorMessage;
|
||||
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
|
||||
private void HandleWavFilesSelected(IReadOnlyList<IBrowserFile> files)
|
||||
{
|
||||
_errorMessage = null;
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Snackbar.Add($"Skipped '{file.Name}' — not a .wav file.", Severity.Warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
_tracks.Add(new BatchRowModel
|
||||
{
|
||||
WavFile = file,
|
||||
TrackName = Path.GetFileNameWithoutExtension(file.Name)
|
||||
});
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0 && _tracks.Count > 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveUp(int i)
|
||||
{
|
||||
if (i == 0) return;
|
||||
(_tracks[i], _tracks[i - 1]) = (_tracks[i - 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i - 1;
|
||||
else if (_selectedIndex == i - 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void MoveDown(int i)
|
||||
{
|
||||
if (i == _tracks.Count - 1) return;
|
||||
(_tracks[i], _tracks[i + 1]) = (_tracks[i + 1], _tracks[i]);
|
||||
if (_selectedIndex == i) _selectedIndex = i + 1;
|
||||
else if (_selectedIndex == i + 1) _selectedIndex = i;
|
||||
}
|
||||
|
||||
private void RemoveRow(int i)
|
||||
{
|
||||
_tracks.RemoveAt(i);
|
||||
if (i < _selectedIndex) _selectedIndex--;
|
||||
if (_selectedIndex >= _tracks.Count) _selectedIndex = _tracks.Count - 1;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_albumName))
|
||||
{
|
||||
_errorMessage = "Album Name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_artist))
|
||||
{
|
||||
_errorMessage = "Artist is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracks.Count == 0)
|
||||
{
|
||||
_errorMessage = "Add at least one track.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_releaseDate)
|
||||
&& !DateOnly.TryParseExact(_releaseDate, "yyyy-MM-dd", out _))
|
||||
{
|
||||
_errorMessage = "Release Date must be in YYYY-MM-DD format.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var t in _tracks)
|
||||
{
|
||||
if (t.WavFile is null)
|
||||
{
|
||||
_errorMessage = $"'{t.TrackName}' has no WAV file selected.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
{
|
||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
|
||||
_imagePath = null; // Clear any stale uploaded path from a prior partial attempt.
|
||||
_uploading = true;
|
||||
_uploadedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art once; abort the submit if it fails so we never
|
||||
// create tracks expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
var trackNumber = i + 1; // 1-based ordinal from list position
|
||||
|
||||
row.Status = BatchRowStatus.Uploading;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
await using var wavStream = row.WavFile!.OpenReadStream(MaxUploadBytes);
|
||||
var result = await CmsTrackService.UploadTrackAsync(
|
||||
wavStream,
|
||||
row.WavFile.Name,
|
||||
row.WavFile.ContentType,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
row.WavFile.Name,
|
||||
createdByUserId,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = error;
|
||||
failed++;
|
||||
Logger.LogWarning("Batch upload: track '{TrackName}' failed: {Error}", row.TrackName, error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern TrackNew/TrackEdit use.
|
||||
if (_imagePath is { } imgPath)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
result.Value.Id,
|
||||
row.TrackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_albumName) ? null : _albumName,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath,
|
||||
_releaseType,
|
||||
trackNumber);
|
||||
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Non-blocking: track is persisted; cover art can be linked via TrackEdit.
|
||||
Logger.LogWarning("Batch upload: cover art link failed for '{TrackName}' (id={Id}) — track uploaded but image unlinked",
|
||||
row.TrackName, result.Value.Id);
|
||||
}
|
||||
}
|
||||
|
||||
row.Status = BatchRowStatus.Done;
|
||||
succeeded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Batch upload: exception uploading '{TrackName}'", row.TrackName);
|
||||
row.Status = BatchRowStatus.Failed;
|
||||
row.ErrorMessage = "Upload failed — please try again.";
|
||||
failed++;
|
||||
}
|
||||
|
||||
_uploadedCount++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (failed == 0)
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s).", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_uploading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsAlbumBrowser> Logger
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No releases found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable T="AlbumRow"
|
||||
Items="_rows"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%;"></MudTh>
|
||||
<MudTh Style="width: 1%;">Art</MudTh>
|
||||
<MudTh>Album</MudTh>
|
||||
<MudTh>Artist</MudTh>
|
||||
<MudTh>Genre</MudTh>
|
||||
<MudTh>Release Date</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Tracks</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.IsExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => ToggleExpand(context))" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release.ImagePath))
|
||||
{
|
||||
<div class="cms-album-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-album-thumb cms-album-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Album">@context.Release.Title</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Release.Artist</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Type">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@context.Release.ReleaseType</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Tracks">@context.TrackCount</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Batch Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/album/{Uri.EscapeDataString(context.Release.Title)}/edit")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete release">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
Disabled="@context.IsDeleting"
|
||||
OnClick="@(() => ConfirmAndDeleteAlbum(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.IsExpanded)
|
||||
{
|
||||
<MudTr>
|
||||
<MudTd colspan="9" Style="padding: 0;">
|
||||
@if (context.IsLoading)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-2">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||
<MudText Typo="Typo.body2">Loading tracks…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (context.Tracks is { Count: 0 })
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="pa-4">No tracks found.</MudText>
|
||||
}
|
||||
else if (context.Tracks is not null)
|
||||
{
|
||||
<MudTable T="TrackDto" Items="context.Tracks" Context="track" Dense="true" Hover="false"
|
||||
Elevation="0" Style="background: transparent;">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">#</MudTh>
|
||||
<MudTh>Track Name</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="#">@track.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Track Name">@track.TrackName</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudTd>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ReleaseDto> Releases { get; set; } = Array.Empty<ReleaseDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public EventCallback OnReleasesChanged { get; set; }
|
||||
|
||||
private List<AlbumRow> _rows = new();
|
||||
|
||||
// Tracks the Releases reference last projected into _rows. Guards against OnParametersSet
|
||||
// resurrecting a row we removed locally on delete: VM.Albums is cached for the circuit and is
|
||||
// not re-fetched after a delete, so a blind rebuild every render would bring the deleted album
|
||||
// back. We only re-project when the parent hands us a genuinely new list.
|
||||
private IReadOnlyList<ReleaseDto>? _projectedReleases;
|
||||
|
||||
// The cover-art endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated,
|
||||
// so the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||
private Uri? _contentApiBase;
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
|
||||
// Re-project rows only when the parent supplies a genuinely new release list (reference change).
|
||||
// Local edits to _rows (a removed row after delete) must survive re-renders triggered by the
|
||||
// same cached VM.Albums instance.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_projectedReleases, Releases))
|
||||
{
|
||||
_projectedReleases = Releases;
|
||||
_rows = Releases.Select(r => new AlbumRow { Release = r }).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private string? ThumbUrl(string imagePath) =>
|
||||
_contentApiBase is null
|
||||
? null
|
||||
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||
|
||||
private async Task ToggleExpand(AlbumRow row)
|
||||
{
|
||||
row.IsExpanded = !row.IsExpanded;
|
||||
if (row.IsExpanded && row.Tracks is null && !row.IsLoading)
|
||||
{
|
||||
row.IsLoading = true;
|
||||
StateHasChanged();
|
||||
row.Tracks = await LoadTracksAsync(row.Release.Title);
|
||||
row.IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Albums are small releases; a single page of 100 always covers the full track list (see brief).
|
||||
private async Task<List<TrackDto>> LoadTracksAsync(string albumTitle)
|
||||
{
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
page: 1, pageSize: 100,
|
||||
sortColumn: "TrackNumber", sortDescending: false,
|
||||
album: albumTitle);
|
||||
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
return result.Value.Items.ToList();
|
||||
}
|
||||
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks for '{albumTitle}': {error}", Severity.Error);
|
||||
return new List<TrackDto>();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDeleteAlbum(AlbumRow row)
|
||||
{
|
||||
// Need track IDs to delete; load them if the row was never expanded.
|
||||
row.Tracks ??= await LoadTracksAsync(row.Release.Title);
|
||||
var tracks = row.Tracks;
|
||||
var count = tracks.Count;
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
// Orphaned release: every track was soft-deleted earlier, leaving a 0-track row that
|
||||
// cannot be cleared by deleting tracks. Delete the release record directly instead.
|
||||
await ConfirmAndDeleteEmptyReleaseAsync(row);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"Delete all <strong>{count}</strong> track(s) in <strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong>? This removes metadata and audio for every track."),
|
||||
yesText: "Delete all",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var failures = 0;
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var del = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (!del.Success) failures++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"{count - failures} of {count} track(s) deleted; {failures} failed.", Severity.Warning);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Delete an orphaned release (0 live tracks) via the release endpoint. Mirrors the track-cascade
|
||||
// delete path's row lifecycle: confirm, guard with IsDeleting, then remove the row and notify the
|
||||
// parent so the cached VM.Albums stays in sync with what is shown.
|
||||
private async Task ConfirmAndDeleteEmptyReleaseAsync(AlbumRow row)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete release",
|
||||
markupMessage: new MarkupString(
|
||||
$"<strong>{WebUtility.HtmlEncode(row.Release.Title)}</strong> has no tracks. Delete this empty release record?"),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
row.IsDeleting = true;
|
||||
StateHasChanged();
|
||||
|
||||
var result = await CmsTrackService.DeleteReleaseAsync(row.Release.Id);
|
||||
|
||||
row.IsDeleting = false;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{row.Release.Title}'.", Severity.Success);
|
||||
_rows.Remove(row);
|
||||
await OnReleasesChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private sealed class AlbumRow
|
||||
{
|
||||
public required ReleaseDto Release { get; init; }
|
||||
public List<TrackDto>? Tracks { get; set; } // null = not yet loaded
|
||||
public bool IsExpanded { get; set; }
|
||||
public bool IsLoading { get; set; }
|
||||
public bool IsDeleting { get; set; }
|
||||
|
||||
// Server-projected count from GetReleasesAsync. Drives the Tracks column without a lazy load.
|
||||
public int TrackCount => Release.TrackCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.cms-album-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-album-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Class="mt-4" />
|
||||
}
|
||||
else if (Genres.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Class="mt-4">No genres found.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="3" Class="mt-2">
|
||||
@foreach (var genre in Genres)
|
||||
{
|
||||
var isExpanded = ExpandedGenre == genre.Genre;
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="@(isExpanded ? 4 : 1)"
|
||||
Style="cursor: pointer;"
|
||||
@onclick="@(() => ToggleGenre(genre.Genre))">
|
||||
<div class="@SwatchClass(isExpanded)"></div>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6">@genre.Genre</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary">@genre.TrackCount track(s)</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@if (ExpandedGenre is not null)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.h6" Class="mb-2">@ExpandedGenre</MudText>
|
||||
<CmsTrackGrid @key="ExpandedGenre" GenreFilter="@ExpandedGenre" ShowAddButton="false" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<GenreSummaryDto> Genres { get; set; } = Array.Empty<GenreSummaryDto>();
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public string? ExpandedGenre { get; set; }
|
||||
[Parameter] public EventCallback<string?> OnExpandedGenreChanged { get; set; }
|
||||
|
||||
// The view model owns the toggle (selecting the open genre collapses it), so we pass the raw
|
||||
// clicked genre rather than pre-computing the next state here — keeps the toggle logic single-sourced.
|
||||
private async Task ToggleGenre(string genre) =>
|
||||
await OnExpandedGenreChanged.InvokeAsync(genre);
|
||||
|
||||
private static string SwatchClass(bool isExpanded) =>
|
||||
isExpanded ? "cms-genre-swatch cms-genre-swatch--active" : "cms-genre-swatch";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.cms-genre-swatch {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.cms-genre-swatch--active {
|
||||
background-color: var(--mud-palette-primary-hover);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
@using System.Net
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.DTOs
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<CmsTrackGrid> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@if (ShowAddButton)
|
||||
{
|
||||
<MudStack Row="true" Justify="Justify.FlexEnd" Class="mb-2">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/upload">
|
||||
Add Track
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="@PageSize"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Track #</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Art</MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Waveform</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track #">@context.TrackNumber</MudTd>
|
||||
<MudTd DataLabel="Art">
|
||||
@if (!string.IsNullOrEmpty(context.Release?.ImagePath))
|
||||
{
|
||||
<div class="cms-track-thumb"
|
||||
style="background-image: url('@ThumbUrl(context.Release.ImagePath)');"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="cms-track-thumb cms-track-thumb--fallback"></div>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@(context.Release?.Artist ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Release?.Title ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Release?.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.Release?.ReleaseDate?.ToString("d MMMM, yyyy") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Waveform">
|
||||
@if (HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Warning" Size="Size.Small" />
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
<MudTooltip>
|
||||
<TooltipContent>
|
||||
<div class="cms-track-info">
|
||||
<div>Entry: @context.EntryKey</div>
|
||||
<div>File: @(context.OriginalFileName ?? "—")</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
<ChildContent>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" />
|
||||
</ChildContent>
|
||||
</MudTooltip>
|
||||
@if (!HasProfile(context.EntryKey))
|
||||
{
|
||||
<MudTooltip Text="Generate Waveform">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.GraphicEq"
|
||||
Size="Size.Small"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(_bulkRunning || _generating.Contains(context.EntryKey))"
|
||||
OnClick="@(() => GenerateOneAsync(context))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AlbumFilter { get; set; }
|
||||
[Parameter] public string? GenreFilter { get; set; }
|
||||
[Parameter] public bool ShowAddButton { get; set; } = true;
|
||||
[Parameter] public int PageSize { get; set; } = 20;
|
||||
[Parameter] public EventCallback OnTracksChanged { get; set; }
|
||||
[Parameter] public EventCallback OnStatusLoaded { get; set; }
|
||||
|
||||
private MudTable<TrackDto>? _table;
|
||||
|
||||
// EntryKey → HasProfile. Loaded once on init; per-row generate flips a single entry to true.
|
||||
private Dictionary<string, bool> _waveformStatus = new();
|
||||
private readonly HashSet<string> _generating = new();
|
||||
|
||||
// The parent owns "Generate All Missing"; while it runs it disables this grid's per-row buttons.
|
||||
private bool _bulkRunning;
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) lives on DeepDrftAPI and is unauthenticated, so
|
||||
// the browser hits it directly. Base address comes from the same named client the CMS uses.
|
||||
private Uri? _contentApiBase;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_contentApiBase = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
await RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
private bool HasProfile(string entryKey) =>
|
||||
_waveformStatus.TryGetValue(entryKey, out var hasProfile) && hasProfile;
|
||||
|
||||
private string? ThumbUrl(string imagePath) =>
|
||||
_contentApiBase is null
|
||||
? null
|
||||
: new Uri(_contentApiBase, $"api/image/{Uri.EscapeDataString(imagePath)}").ToString();
|
||||
|
||||
/// <summary>Number of tracks with a missing waveform profile — drives the parent's bulk button label.</summary>
|
||||
public int GetMissingCount() => _waveformStatus.Count(kv => !kv.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Reload the full waveform-status map. Called on init and by the parent after a bulk generate so
|
||||
/// the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
public async Task RefreshWaveformStatusAsync()
|
||||
{
|
||||
var result = await CmsTrackService.GetWaveformStatusAsync();
|
||||
_waveformStatus = result.Success && result.Value is not null
|
||||
? result.Value.ToDictionary(s => s.EntryKey, s => s.HasProfile)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
StateHasChanged();
|
||||
await OnStatusLoaded.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Set by the parent while its bulk generate runs so per-row buttons disable.</summary>
|
||||
public void SetBulkRunning(bool running)
|
||||
{
|
||||
_bulkRunning = running;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(
|
||||
pageNumber, state.PageSize, sortColumn, sortDescending,
|
||||
AlbumFilter, GenreFilter, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Release?.Artist ?? "Unknown")}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
await OnTracksChanged.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateOneAsync(TrackDto track)
|
||||
{
|
||||
_generating.Add(track.EntryKey);
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(track.EntryKey);
|
||||
if (result.Success)
|
||||
{
|
||||
_waveformStatus[track.EntryKey] = true;
|
||||
Snackbar.Add($"Generated profile for '{track.TrackName}'.", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}': {error}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", track.EntryKey);
|
||||
Snackbar.Add($"Generate failed for '{track.TrackName}' — please try again.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_generating.Remove(track.EntryKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.cms-track-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cms-track-thumb--fallback {
|
||||
background-color: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.cms-track-info {
|
||||
font-family: monospace;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
@page "/tracks/{Id:long}"
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@attribute [Authorize]
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@@ -60,6 +64,62 @@
|
||||
Label="Genre"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudSelect @bind-Value="_form.ReleaseType"
|
||||
Label="Release Type"
|
||||
Variant="Variant.Outlined">
|
||||
@foreach (var releaseType in Enum.GetValues<ReleaseType>())
|
||||
{
|
||||
<MudSelectItem Value="releaseType">@releaseType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudNumericField @bind-Value="_form.TrackNumber"
|
||||
Label="Track Number"
|
||||
Min="1"
|
||||
Variant="Variant.Outlined" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (ImagePreviewUrl is { } previewUrl)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudImage Src="@previewUrl"
|
||||
Alt="Cover art preview"
|
||||
Elevation="1"
|
||||
Style="max-width: 120px; height: auto; border-radius: 4px;" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Clear cover art" />
|
||||
</MudStack>
|
||||
}
|
||||
else if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">New image selected (not yet saved).</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_busy"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art set.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" />
|
||||
@if (_selectedImageFile is { } selected)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Selected: @selected.Name (will upload on save)</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudDatePicker @bind-Date="_form.ReleaseDate"
|
||||
Label="Release Date"
|
||||
DateFormat="yyyy-MM-dd"
|
||||
@@ -94,11 +154,25 @@
|
||||
private TrackEditForm _form = new();
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
|
||||
private bool CanSave =>
|
||||
!string.IsNullOrWhiteSpace(_form.TrackName)
|
||||
&& !string.IsNullOrWhiteSpace(_form.Artist);
|
||||
|
||||
// The image endpoint (GET api/image/{entryKey}) is unauthenticated, so the browser can hit
|
||||
// DeepDrftAPI directly. Base address comes from the same named client the CMS uses for writes.
|
||||
private string? ImagePreviewUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(_form.ImagePath)) return null;
|
||||
var baseAddress = HttpClientFactory.CreateClient("DeepDrft.Content.Cms").BaseAddress;
|
||||
if (baseAddress is null) return null;
|
||||
return new Uri(baseAddress, $"api/image/{Uri.EscapeDataString(_form.ImagePath)}").ToString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
@@ -123,16 +197,40 @@
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
// Metadata-only update over HTTP — EntryKey is immutable and not sent. The Content
|
||||
// API loads the authoritative row and applies these fields.
|
||||
// Upload any newly picked cover art first; abort the save if it fails so we never
|
||||
// persist metadata pointing at an image that was never stored.
|
||||
if (_selectedImageFile is { } file)
|
||||
{
|
||||
await using var imageStream = file.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var uploadResult = await CmsTrackService.UploadImageAsync(
|
||||
imageStream, file.Name, file.ContentType);
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
var uploadError = uploadResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Image upload failed: {uploadError}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
_form.ImagePath = uploadResult.Value;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
// Metadata update over HTTP — EntryKey is immutable and not sent. The Content API
|
||||
// loads the authoritative row and applies these fields. imagePath is tri-state: an
|
||||
// explicit empty string clears the link, a value sets it.
|
||||
var releaseDate = _form.ReleaseDate is { } d ? DateOnly.FromDateTime(d) : (DateOnly?)null;
|
||||
var updated = await CmsTrackService.UpdateAsync(
|
||||
Id, _form.TrackName, _form.Artist,
|
||||
string.IsNullOrWhiteSpace(_form.Album) ? null : _form.Album,
|
||||
string.IsNullOrWhiteSpace(_form.Genre) ? null : _form.Genre,
|
||||
releaseDate);
|
||||
releaseDate,
|
||||
string.IsNullOrEmpty(_form.ImagePath) ? "" : _form.ImagePath,
|
||||
_form.ReleaseType,
|
||||
_form.TrackNumber);
|
||||
if (updated.Success)
|
||||
{
|
||||
// Album/genre browse lists derive from this track's metadata; drop their cache so
|
||||
// the /tracks browser re-fetches fresh data on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track updated.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
@@ -153,13 +251,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_form.ImagePath = null;
|
||||
_selectedImageFile = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete()
|
||||
{
|
||||
if (_track is null) return;
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
"Delete track",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Artist}? This cannot be undone.",
|
||||
$"Permanently delete \"{_track.TrackName}\" by {_track.Release?.Artist ?? "Unknown"}? This cannot be undone.",
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
@@ -171,6 +280,9 @@
|
||||
var result = await CmsTrackService.DeleteTrackAsync(Id);
|
||||
if (result.Success)
|
||||
{
|
||||
// Deleting a track can empty or alter a release; drop the browse cache so the
|
||||
// /tracks album and genre lists re-fetch fresh counts on next mode switch.
|
||||
VM.Invalidate();
|
||||
Snackbar.Add("Track deleted.", Severity.Success);
|
||||
Nav.NavigateTo("/tracks");
|
||||
}
|
||||
@@ -197,17 +309,23 @@
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public DateTime? ReleaseDate { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
public static TrackEditForm From(TrackDto track) => new()
|
||||
{
|
||||
TrackName = track.TrackName,
|
||||
Artist = track.Artist,
|
||||
Album = track.Album,
|
||||
Genre = track.Genre,
|
||||
ReleaseDate = track.ReleaseDate is { } d
|
||||
Artist = track.Release?.Artist ?? string.Empty,
|
||||
Album = track.Release?.Title,
|
||||
Genre = track.Release?.Genre,
|
||||
ImagePath = track.Release?.ImagePath,
|
||||
ReleaseDate = track.Release?.ReleaseDate is { } d
|
||||
? d.ToDateTime(TimeOnly.MinValue)
|
||||
: null
|
||||
: null,
|
||||
ReleaseType = track.Release?.ReleaseType ?? ReleaseType.Single,
|
||||
TrackNumber = track.TrackNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,179 @@
|
||||
@page "/tracks"
|
||||
@using System.Net
|
||||
@page "/tracks/albums"
|
||||
@page "/tracks/genres"
|
||||
@using DeepDrftManager.Services
|
||||
@attribute [Authorize]
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackList> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@attribute [Authorize]
|
||||
|
||||
<PageTitle>Tracks — DeepDrft CMS</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="mb-4">
|
||||
<MudText Typo="Typo.h3">Tracks</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
Href="/tracks/new">
|
||||
Add Track
|
||||
</MudButton>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh"
|
||||
Disabled="@(_bulkRunning || (_grid?.GetMissingCount() ?? 0) == 0)"
|
||||
OnClick="GenerateAllMissingAsync">
|
||||
@if (_bulkRunning)
|
||||
{
|
||||
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
|
||||
<span>Generating @_bulkDone / @_bulkTotal…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Generate All Missing (@(_grid?.GetMissingCount() ?? 0))</span>
|
||||
}
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudTable T="TrackDto"
|
||||
@ref="_table"
|
||||
ServerData="LoadServerData"
|
||||
Hover="true"
|
||||
Striped="true"
|
||||
Dense="true"
|
||||
Bordered="false"
|
||||
FixedHeader="true"
|
||||
RowsPerPage="20"
|
||||
AllowUnsorted="false">
|
||||
<NoRecordsContent>
|
||||
<MudText Typo="Typo.body1">No tracks found.</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText Typo="Typo.body1">Loading tracks…</MudText>
|
||||
</LoadingContent>
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortLabel="TrackName" T="TrackDto" InitialDirection="SortDirection.Ascending">Track Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Artist" T="TrackDto">Artist</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Album" T="TrackDto">Album</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="Genre" T="TrackDto">Genre</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortLabel="ReleaseDate" T="TrackDto">Release Date</MudTableSortLabel></MudTh>
|
||||
<MudTh>Entry Key</MudTh>
|
||||
<MudTh Style="width: 1%; white-space: nowrap;">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Track Name">@context.TrackName</MudTd>
|
||||
<MudTd DataLabel="Artist">@context.Artist</MudTd>
|
||||
<MudTd DataLabel="Album">@(context.Album ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Genre">@(context.Genre ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Release Date">@(context.ReleaseDate?.ToString("yyyy-MM-dd") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Entry Key"><MudText Typo="Typo.caption" Style="font-family: monospace;">@context.EntryKey</MudText></MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="Edit">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Href="@($"/tracks/{context.Id}")" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Small"
|
||||
Color="Color.Error"
|
||||
OnClick="@(() => ConfirmAndDelete(context))" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager PageSizeOptions="new[] { 10, 20, 50 }" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<MudToggleGroup T="BrowseMode"
|
||||
Value="VM.Mode"
|
||||
ValueChanged="OnModeChanged"
|
||||
SelectionMode="SelectionMode.SingleSelection"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Class="mb-4">
|
||||
<MudToggleItem Value="BrowseMode.Tracks">Tracks</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Albums">Releases</MudToggleItem>
|
||||
<MudToggleItem Value="BrowseMode.Genres">Genres</MudToggleItem>
|
||||
</MudToggleGroup>
|
||||
|
||||
@if (VM.Mode == BrowseMode.Tracks)
|
||||
{
|
||||
<CmsTrackGrid @ref="_grid" ShowAddButton="true" PageSize="20" OnStatusLoaded="StateHasChanged" />
|
||||
}
|
||||
else if (VM.Mode == BrowseMode.Albums)
|
||||
{
|
||||
<CmsAlbumBrowser Releases="VM.Albums"
|
||||
IsLoading="VM.AlbumsLoading"
|
||||
OnReleasesChanged="OnAlbumsChanged" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<CmsGenreBrowser Genres="VM.Genres"
|
||||
IsLoading="VM.GenresLoading"
|
||||
ExpandedGenre="@VM.ExpandedGenre"
|
||||
OnExpandedGenreChanged="OnExpandedGenreChanged" />
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private MudTable<TrackDto>? _table;
|
||||
private CmsTrackGrid? _grid;
|
||||
|
||||
private async Task<TableData<TrackDto>> LoadServerData(TableState state, CancellationToken cancellationToken)
|
||||
// The album browser owns its own row state and removes a deleted release locally. Invalidate the
|
||||
// VM cache so genres and album counts reflect the deletion on next mode switch.
|
||||
private void OnAlbumsChanged()
|
||||
{
|
||||
var pageNumber = state.Page + 1; // MudTable is 0-based, service is 1-based.
|
||||
var sortColumn = string.IsNullOrEmpty(state.SortLabel) ? "TrackName" : state.SortLabel;
|
||||
var sortDescending = state.SortDirection == SortDirection.Descending;
|
||||
|
||||
var result = await CmsTrackService.GetPagedAsync(pageNumber, state.PageSize, sortColumn, sortDescending, cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
var errorText = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load tracks: {errorText}", Severity.Error);
|
||||
return new TableData<TrackDto> { Items = Array.Empty<TrackDto>(), TotalItems = 0 };
|
||||
}
|
||||
|
||||
var page = result.Value;
|
||||
return new TableData<TrackDto>
|
||||
{
|
||||
Items = page.Items,
|
||||
TotalItems = page.TotalCount
|
||||
};
|
||||
VM.Invalidate();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ConfirmAndDelete(TrackDto track)
|
||||
// Local state for the parent-owned "Generate All Missing" bulk run.
|
||||
private bool _bulkRunning;
|
||||
private int _bulkTotal;
|
||||
private int _bulkDone;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
title: "Delete track",
|
||||
markupMessage: new MarkupString($"Delete <strong>{WebUtility.HtmlEncode(track.TrackName)}</strong> by {WebUtility.HtmlEncode(track.Artist)}? This removes both the metadata row and the underlying audio entry."),
|
||||
yesText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
var uri = NavigationManager.Uri;
|
||||
var initial = uri.Contains("/tracks/albums", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Albums
|
||||
: uri.Contains("/tracks/genres", StringComparison.OrdinalIgnoreCase)
|
||||
? BrowseMode.Genres
|
||||
: BrowseMode.Tracks;
|
||||
await VM.SwitchModeAsync(initial);
|
||||
}
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try
|
||||
private async Task OnModeChanged(BrowseMode mode)
|
||||
{
|
||||
await VM.SwitchModeAsync(mode);
|
||||
var path = mode switch
|
||||
{
|
||||
var result = await CmsTrackService.DeleteTrackAsync(track.Id);
|
||||
if (result.Success)
|
||||
{
|
||||
Snackbar.Add($"Deleted '{track.TrackName}'.", Severity.Success);
|
||||
if (_table is not null) await _table.ReloadServerData();
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Delete failed: {error}", Severity.Error);
|
||||
}
|
||||
BrowseMode.Albums => "/tracks/albums",
|
||||
BrowseMode.Genres => "/tracks/genres",
|
||||
_ => "/tracks"
|
||||
};
|
||||
NavigationManager.NavigateTo(path, replace: true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnExpandedGenreChanged(string? genre)
|
||||
{
|
||||
VM.SetExpandedGenre(genre);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backfill every track missing a waveform profile, one request at a time so a large backfill
|
||||
/// does not flood the API with concurrent WAV decodes. On completion, refreshes the grid's
|
||||
/// status map so the per-row icons reflect the new state.
|
||||
/// </summary>
|
||||
private async Task GenerateAllMissingAsync()
|
||||
{
|
||||
var statusResult = await CmsTrackService.GetWaveformStatusAsync();
|
||||
if (!statusResult.Success || statusResult.Value is null)
|
||||
{
|
||||
var error = statusResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
Snackbar.Add($"Failed to load waveform status: {error}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var missing = statusResult.Value.Where(s => !s.HasProfile).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
Logger.LogError(ex, "Delete failed for track {TrackId}", track.Id);
|
||||
Snackbar.Add("Delete failed — please try again.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_bulkRunning = true;
|
||||
_bulkTotal = missing.Count;
|
||||
_bulkDone = 0;
|
||||
_grid?.SetBulkRunning(true);
|
||||
var failures = 0;
|
||||
|
||||
foreach (var status in missing)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await CmsTrackService.GenerateWaveformProfileAsync(status.EntryKey);
|
||||
if (!result.Success)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Waveform generation failed for {EntryKey}", status.EntryKey);
|
||||
failures++;
|
||||
}
|
||||
_bulkDone++;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
_bulkRunning = false;
|
||||
_grid?.SetBulkRunning(false);
|
||||
|
||||
if (_grid is not null)
|
||||
{
|
||||
await _grid.RefreshWaveformStatusAsync();
|
||||
}
|
||||
|
||||
var succeeded = missing.Count - failures;
|
||||
if (failures == 0)
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s).", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Generated {succeeded} profile(s); {failures} failed.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/tracks/new"
|
||||
@using System.Security.Claims
|
||||
@using DeepDrftManager.Services
|
||||
@using DeepDrftModels.Enums
|
||||
@attribute [Authorize]
|
||||
|
||||
@inject ICmsTrackService CmsTrackService
|
||||
@@ -8,6 +9,7 @@
|
||||
@inject NavigationManager Navigation
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<TrackNew> Logger
|
||||
@inject CmsTrackBrowserViewModel VM
|
||||
|
||||
<PageTitle>Add Track — DeepDrft CMS</PageTitle>
|
||||
|
||||
@@ -29,6 +31,34 @@
|
||||
<MudTextField @bind-Value="_artist" Label="Artist" Required="true" RequiredError="Artist is required" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_album" Label="Album" Variant="Variant.Outlined" />
|
||||
<MudTextField @bind-Value="_genre" Label="Genre" Variant="Variant.Outlined" />
|
||||
|
||||
<MudField Label="Cover Art" Variant="Variant.Outlined" InnerPadding="false">
|
||||
<MudStack Spacing="3">
|
||||
@if (_selectedImageFile is { } selectedImage)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">Selected: @selectedImage.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Clear"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
Disabled="_isUploading"
|
||||
OnClick="ClearImage"
|
||||
aria-label="Cancel image selection" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Default">No cover art — optional.</MudText>
|
||||
}
|
||||
|
||||
<InputFile OnChange="HandleImageFileSelected" accept="image/*" disabled="@_isUploading" />
|
||||
@if (_selectedImageFile is not null)
|
||||
{
|
||||
<MudText Typo="Typo.caption">Will upload on save.</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudField>
|
||||
|
||||
<MudTextField @bind-Value="_releaseDate" Label="Release Date (YYYY-MM-DD)" Placeholder="2024-01-15" Variant="Variant.Outlined" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||
@@ -67,6 +97,8 @@
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
private IBrowserFile? _selectedImageFile;
|
||||
private string? _imagePath;
|
||||
private string _trackName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _album = string.Empty;
|
||||
@@ -81,6 +113,18 @@
|
||||
_errorMessage = null;
|
||||
}
|
||||
|
||||
private void HandleImageFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_selectedImageFile = e.File;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private void ClearImage()
|
||||
{
|
||||
_selectedImageFile = null;
|
||||
_imagePath = null;
|
||||
}
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
_errorMessage = null;
|
||||
@@ -130,6 +174,21 @@
|
||||
_isUploading = true;
|
||||
try
|
||||
{
|
||||
// Upload any selected cover art first; abort the submit if it fails so we never
|
||||
// create a track expecting an image that was never stored in the vault.
|
||||
if (_selectedImageFile is { } imgFile)
|
||||
{
|
||||
await using var imgStream = imgFile.OpenReadStream(maxAllowedSize: 50_000_000);
|
||||
var imgResult = await CmsTrackService.UploadImageAsync(imgStream, imgFile.Name, imgFile.ContentType);
|
||||
if (!imgResult.Success)
|
||||
{
|
||||
var imgError = imgResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Image upload failed: {imgError}";
|
||||
return;
|
||||
}
|
||||
_imagePath = imgResult.Value;
|
||||
}
|
||||
|
||||
// OpenReadStream streams chunks from the browser via the SignalR circuit; the
|
||||
// service wraps it in StreamContent so the whole file is never materialised in
|
||||
// memory before DeepDrftAPI receives it.
|
||||
@@ -144,11 +203,35 @@
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : _releaseDate,
|
||||
createdByUserId);
|
||||
_selectedFile.Name,
|
||||
createdByUserId,
|
||||
releaseType: ReleaseType.Single,
|
||||
trackNumber: 1);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with a
|
||||
// follow-up metadata update — same two-step pattern TrackEdit uses.
|
||||
if (_imagePath is { } imgPath && result.Value is { } created)
|
||||
{
|
||||
var linkResult = await CmsTrackService.UpdateAsync(
|
||||
created.Id,
|
||||
_trackName,
|
||||
_artist,
|
||||
string.IsNullOrWhiteSpace(_album) ? null : _album,
|
||||
string.IsNullOrWhiteSpace(_genre) ? null : _genre,
|
||||
string.IsNullOrWhiteSpace(_releaseDate) ? null : (DateOnly?)DateOnly.ParseExact(_releaseDate, "yyyy-MM-dd"),
|
||||
imgPath);
|
||||
if (!linkResult.Success)
|
||||
{
|
||||
// Track was created; image is in the vault but unlinked. Non-blocking —
|
||||
// the user can attach it via Edit.
|
||||
Snackbar.Add("Track uploaded, but cover art could not be linked. You can add it via Edit.", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add($"Uploaded '{_trackName}'.", Severity.Success);
|
||||
VM.Invalidate();
|
||||
Navigation.NavigateTo("/tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,3 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ builder.Services.AddMudServices();
|
||||
// DeepDrftAPI API via the named clients below — the Manager holds no in-process data layer.
|
||||
builder.Services.AddScoped<ICmsTrackService, CmsTrackService>();
|
||||
|
||||
// Per-circuit browse state for the /tracks page (mode toggle + album/genre datasets).
|
||||
builder.Services.AddScoped<CmsTrackBrowserViewModel>();
|
||||
|
||||
// AuthBlocksWeb: server-side cascading auth state plus the JWT client services used by the
|
||||
// /account/login + /account/logout Razor pages that ship in the AuthBlocksWeb RCL.
|
||||
// The auth API lives on DeepDrftAPI, so pass its URL — not Manager's own Kestrel URL.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
|
||||
namespace DeepDrftManager.Services;
|
||||
|
||||
/// <summary>The three browse dimensions for the /tracks page.</summary>
|
||||
public enum BrowseMode
|
||||
{
|
||||
Tracks,
|
||||
Albums,
|
||||
Genres,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the /tracks browser's current mode plus the album- and genre-mode datasets. Scoped per
|
||||
/// circuit. Album and genre lists are fetched lazily on first switch into their mode and cached for
|
||||
/// the circuit's lifetime; Track mode owns its own paging inside <c>CmsTrackGrid</c> and needs no
|
||||
/// state here.
|
||||
/// </summary>
|
||||
public class CmsTrackBrowserViewModel
|
||||
{
|
||||
private readonly ICmsTrackService _trackService;
|
||||
|
||||
public CmsTrackBrowserViewModel(ICmsTrackService trackService)
|
||||
{
|
||||
_trackService = trackService;
|
||||
}
|
||||
|
||||
public BrowseMode Mode { get; private set; } = BrowseMode.Tracks;
|
||||
|
||||
// Album mode.
|
||||
public IReadOnlyList<ReleaseDto> Albums { get; private set; } = Array.Empty<ReleaseDto>();
|
||||
public bool AlbumsLoading { get; private set; }
|
||||
|
||||
// Genre mode.
|
||||
public IReadOnlyList<GenreSummaryDto> Genres { get; private set; } = Array.Empty<GenreSummaryDto>();
|
||||
public bool GenresLoading { get; private set; }
|
||||
public string? ExpandedGenre { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active mode, lazily loading the album or genre dataset on first entry. Collapses
|
||||
/// any expanded genre row. The grid in Track mode owns its own data, so no fetch happens there.
|
||||
/// </summary>
|
||||
public async Task SwitchModeAsync(BrowseMode mode)
|
||||
{
|
||||
Mode = mode;
|
||||
ExpandedGenre = null; // collapse on mode switch
|
||||
|
||||
if (mode == BrowseMode.Albums && Albums.Count == 0 && !AlbumsLoading)
|
||||
{
|
||||
AlbumsLoading = true;
|
||||
var result = await _trackService.GetReleasesAsync();
|
||||
Albums = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<ReleaseDto>();
|
||||
AlbumsLoading = false;
|
||||
}
|
||||
else if (mode == BrowseMode.Genres && Genres.Count == 0 && !GenresLoading)
|
||||
{
|
||||
GenresLoading = true;
|
||||
var result = await _trackService.GetGenreSummariesAsync();
|
||||
Genres = result.Success && result.Value is not null
|
||||
? result.Value
|
||||
: Array.Empty<GenreSummaryDto>();
|
||||
GenresLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Toggle the expanded genre row. Selecting the already-expanded genre collapses it.</summary>
|
||||
public void SetExpandedGenre(string? genre)
|
||||
{
|
||||
ExpandedGenre = ExpandedGenre == genre ? null : genre;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the cached album and genre datasets so the next <see cref="SwitchModeAsync"/> into
|
||||
/// either mode re-fetches from the API. Call after a track or release mutation (edit, delete)
|
||||
/// since both datasets are derived from the catalogue and go stale on any such change.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Albums = Array.Empty<ReleaseDto>();
|
||||
Genres = Array.Empty<GenreSummaryDto>();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -39,7 +40,10 @@ public class CmsTrackService : ICmsTrackService
|
||||
string? album,
|
||||
string? genre,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Rebuild the multipart container so the boundary is owned by HttpClient and the
|
||||
@@ -48,13 +52,17 @@ public class CmsTrackService : ICmsTrackService
|
||||
var wavContent = new StreamContent(wavStream);
|
||||
wavContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "audio/wav" : contentType);
|
||||
multipart.Add(wavContent, "wav", fileName);
|
||||
multipart.Add(wavContent, "audioFile", fileName);
|
||||
multipart.Add(new StringContent(trackName), "trackName");
|
||||
multipart.Add(new StringContent(artist), "artist");
|
||||
if (!string.IsNullOrWhiteSpace(album)) multipart.Add(new StringContent(album), "album");
|
||||
if (!string.IsNullOrWhiteSpace(genre)) multipart.Add(new StringContent(genre), "genre");
|
||||
if (!string.IsNullOrWhiteSpace(releaseDate)) multipart.Add(new StringContent(releaseDate), "releaseDate");
|
||||
// Explicit field — decouples the admin-visible display name from the WAV part's content-disposition filename.
|
||||
if (!string.IsNullOrWhiteSpace(originalFileName)) multipart.Add(new StringContent(originalFileName), "originalFileName");
|
||||
multipart.Add(new StringContent(createdByUserId.ToString()), "createdByUserId");
|
||||
multipart.Add(new StringContent(releaseType.ToString()), "releaseType");
|
||||
multipart.Add(new StringContent(trackNumber.ToString()), "trackNumber");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, UploadPath) { Content = multipart };
|
||||
@@ -144,8 +152,42 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.DeleteAsync($"api/track/release/{releaseId}", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for delete of release {ReleaseId}", releaseId);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Release not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API delete failed for release {ReleaseId}: {Status} {Body}", releaseId, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to delete release.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
string? album = null, string? genre = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -154,6 +196,14 @@ public class CmsTrackService : ICmsTrackService
|
||||
{
|
||||
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
query += $"&album={Uri.EscapeDataString(album)}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
query += $"&genre={Uri.EscapeDataString(genre)}";
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
@@ -238,9 +288,89 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
|
||||
};
|
||||
|
||||
public async Task<ResultContainer<string>> UploadImageAsync(
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType) || !KnownImageMimeTypes.Contains(contentType))
|
||||
{
|
||||
_logger.LogWarning("UploadImageAsync rejected: unsupported or missing content type '{ContentType}'", contentType);
|
||||
return ResultContainer<string>.CreateFailResult($"Unsupported image type: {contentType}. Accepted: JPEG, PNG, GIF, WebP, SVG, BMP.");
|
||||
}
|
||||
|
||||
using var multipart = new MultipartFormDataContent();
|
||||
var imageContent = new StreamContent(imageStream);
|
||||
imageContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||
multipart.Add(imageContent, "image", fileName);
|
||||
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "api/image/upload") { Content = multipart };
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for image upload of {FileName}", fileName);
|
||||
return ResultContainer<string>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
_logger.LogError("Content API returned {Status} for image upload of {FileName}: {Body}", statusCode, fileName, body);
|
||||
return ResultContainer<string>.CreateFailResult("Image upload failed on the content server.");
|
||||
}
|
||||
|
||||
// 4xx: body is user-friendly validation text from DeepDrftAPI — relay as-is.
|
||||
_logger.LogWarning("Content API rejected image upload: {Status} {Body}", statusCode, body);
|
||||
return ResultContainer<string>.CreateFailResult(
|
||||
string.IsNullOrWhiteSpace(body) ? $"Image upload rejected ({statusCode})." : body);
|
||||
}
|
||||
|
||||
ImageUploadResponse? payload;
|
||||
try
|
||||
{
|
||||
payload = await response.Content.ReadFromJsonAsync<ImageUploadResponse>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize image upload response from Content API");
|
||||
return ResultContainer<string>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (payload is null || string.IsNullOrWhiteSpace(payload.EntryKey))
|
||||
{
|
||||
_logger.LogError("Content API returned an empty entry key for image upload");
|
||||
return ResultContainer<string>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<string>.CreatePassResult(payload.EntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ImageUploadResponse(string EntryKey);
|
||||
|
||||
public async Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
@@ -251,6 +381,9 @@ public class CmsTrackService : ICmsTrackService
|
||||
album,
|
||||
genre,
|
||||
releaseDate,
|
||||
imagePath,
|
||||
releaseType = releaseType.HasValue ? (int?)releaseType.Value : null,
|
||||
trackNumber,
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
@@ -281,4 +414,183 @@ public class CmsTrackService : ICmsTrackService
|
||||
return Result.CreateFailResult("Failed to update track.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/waveform-status", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for waveform status");
|
||||
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API waveform status failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Failed to load waveform status.");
|
||||
}
|
||||
|
||||
WaveformStatusDto[]? status;
|
||||
try
|
||||
{
|
||||
status = await response.Content.ReadFromJsonAsync<WaveformStatusDto[]>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize waveform status from Content API response");
|
||||
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null waveform status list");
|
||||
return ResultContainer<WaveformStatusDto[]>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<WaveformStatusDto[]>.CreatePassResult(status);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.PostAsync($"api/track/{Uri.EscapeDataString(entryKey)}/waveform", null, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for waveform generation of {EntryKey}", entryKey);
|
||||
return Result.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return Result.CreateFailResult("Track audio not found.");
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogError("Content API waveform generation failed for {EntryKey}: {Status} {Body}", entryKey, (int)response.StatusCode, body);
|
||||
return Result.CreateFailResult("Failed to generate waveform profile.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/albums", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for releases");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API releases failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Failed to load albums.");
|
||||
}
|
||||
|
||||
List<ReleaseDto>? releases;
|
||||
try
|
||||
{
|
||||
releases = await response.Content.ReadFromJsonAsync<List<ReleaseDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize releases from Content API response");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (releases is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null releases list");
|
||||
return ResultContainer<List<ReleaseDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<ReleaseDto>>.CreatePassResult(releases);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/track/genres", ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for genre summaries");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API genre summaries failed: {Status}", (int)response.StatusCode);
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Failed to load genres.");
|
||||
}
|
||||
|
||||
List<GenreSummaryDto>? genres;
|
||||
try
|
||||
{
|
||||
genres = await response.Content.ReadFromJsonAsync<List<GenreSummaryDto>>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize genre summaries from Content API response");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
if (genres is null)
|
||||
{
|
||||
_logger.LogError("Content API returned a null genre summaries list");
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreateFailResult("Content API returned an empty response.");
|
||||
}
|
||||
|
||||
return ResultContainer<List<GenreSummaryDto>>.CreatePassResult(genres);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Re-use the paged endpoint: a single-item page carries the full TotalCount, so no
|
||||
// dedicated count endpoint is needed.
|
||||
var paged = await GetPagedAsync(page: 1, pageSize: 1, sortColumn: null, sortDescending: false, ct: ct);
|
||||
if (!paged.Success || paged.Value is null)
|
||||
{
|
||||
var error = paged.Messages.FirstOrDefault()?.Message ?? "Failed to load track count.";
|
||||
return ResultContainer<int>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
return ResultContainer<int>.CreatePassResult(paged.Value.TotalCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -15,6 +16,8 @@ public interface ICmsTrackService
|
||||
/// Proxy a WAV upload to DeepDrftAPI. The Content API owns the dual-database write and
|
||||
/// returns the persisted track carrying the SQL-assigned <c>Id</c>. A vault-without-SQL
|
||||
/// orphan is handled and logged server-side; here it surfaces as a failed result.
|
||||
/// <paramref name="originalFileName"/> is the browser's filename, captured at upload time and
|
||||
/// stored as metadata; it is not user-editable afterwards.
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
@@ -25,7 +28,10 @@ public interface ICmsTrackService
|
||||
string? album,
|
||||
string? genre,
|
||||
string? releaseDate,
|
||||
string? originalFileName,
|
||||
long createdByUserId,
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -35,10 +41,19 @@ public interface ICmsTrackService
|
||||
Task<Result> DeleteTrackAsync(long id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>.
|
||||
/// Soft-delete a release record via DELETE api/track/release/{id}. Use when a release
|
||||
/// has no live tracks and needs to be removed from the albums browser.
|
||||
/// </summary>
|
||||
Task<Result> DeleteReleaseAsync(long releaseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch a page of track metadata from the Content API's <c>GET api/track/page</c>. Optional
|
||||
/// <paramref name="album"/> and <paramref name="genre"/> filters narrow the result to a single
|
||||
/// release title or genre; null leaves the dimension unfiltered.
|
||||
/// </summary>
|
||||
Task<ResultContainer<PagedResult<TrackDto>>> GetPagedAsync(
|
||||
int page, int pageSize, string? sortColumn, bool sortDescending,
|
||||
string? album = null, string? genre = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -47,12 +62,49 @@ public interface ICmsTrackService
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto?>> GetByIdAsync(long id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upload a cover-art image to the images vault via <c>POST api/image/upload</c>.
|
||||
/// Returns the generated entry key on success. Maps a 400 to a validation failure message.
|
||||
/// </summary>
|
||||
Task<ResultContainer<string>> UploadImageAsync(
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string contentType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update a track's metadata via <c>PUT api/track/meta/{id}</c>. EntryKey is immutable and
|
||||
/// not part of the update.
|
||||
/// not part of the update. <paramref name="imagePath"/> is tri-state: <c>null</c> leaves the
|
||||
/// cover art unchanged, <c>""</c> clears it, and any other value sets it.
|
||||
/// </summary>
|
||||
Task<Result> UpdateAsync(
|
||||
long id, string trackName, string artist,
|
||||
string? album, string? genre, DateOnly? releaseDate,
|
||||
string? imagePath = null,
|
||||
ReleaseType? releaseType = null,
|
||||
int? trackNumber = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch per-track waveform profile status from <c>GET api/track/waveform-status</c> for the
|
||||
/// CMS PreProcessing panel. Unpaged — the admin catalogue is small.
|
||||
/// </summary>
|
||||
Task<ResultContainer<WaveformStatusDto[]>> GetWaveformStatusAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Trigger waveform profile generation for a single track via
|
||||
/// <c>POST api/track/{entryKey}/waveform</c>. Maps a 404 to a "Track audio not found." failure.
|
||||
/// </summary>
|
||||
Task<Result> GenerateWaveformProfileAsync(string entryKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all releases with track counts from GET api/track/albums.</summary>
|
||||
Task<ResultContainer<List<ReleaseDto>>> GetReleasesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns all distinct genres with track counts from GET api/track/genres.</summary>
|
||||
Task<ResultContainer<List<GenreSummaryDto>>> GetGenreSummariesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total track count by calling GET api/track/page with pageSize=1 and reading TotalCount.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetTrackCountAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Suppress the browser focus ring that FocusOnNavigate triggers on h1 after navigation. */
|
||||
h1:focus-visible { outline: none; }
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// One distinct album with its track count and a representative cover image key. Backs the
|
||||
/// /albums browse grid.
|
||||
/// </summary>
|
||||
[Obsolete("Replaced by ReleaseDto. Use ITrackService.GetReleases().")]
|
||||
public class AlbumSummaryDto
|
||||
{
|
||||
public required string Album { get; set; }
|
||||
public int TrackCount { get; set; }
|
||||
|
||||
/// <summary>ImagePath of the first track in the album that has one; null when none do.</summary>
|
||||
public string? CoverImageKey { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>One distinct genre with its track count. Backs the /genres browse list.</summary>
|
||||
public class GenreSummaryDto
|
||||
{
|
||||
public required string Genre { get; set; }
|
||||
public int TrackCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Models;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
// Mirror of ReleaseEntity (Phase 8 §8.0). Inherits Id, CreatedAt, UpdatedAt from BaseModel
|
||||
// (Cerebellum.BlazorBlocks.Models). No `required` members — BlazorBlocks's Manager<> generic
|
||||
// constraint requires `new()`, which does not compose with required members (see TrackDto header).
|
||||
// TrackConverter assigns every field on the round-trip, so an empty default is never observable.
|
||||
public class ReleaseDto : BaseModel
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public long? CreatedByUserId { get; set; }
|
||||
|
||||
// Read-model field: count of non-deleted tracks in this release. Not on ReleaseEntity — the
|
||||
// service projects it from the joined Tracks collection so the /albums browse grid and the CMS
|
||||
// dashboard can show a per-album track count. Defaults to 0 when not populated.
|
||||
public int TrackCount { get; set; }
|
||||
}
|
||||
@@ -5,17 +5,17 @@ namespace DeepDrftModels.DTOs;
|
||||
// Inherits Id, CreatedAt, UpdatedAt from BaseModel (Cerebellum.BlazorBlocks.Models).
|
||||
// BlazorBlocks's Manager<> generic constraint requires `new()` on the model type, which
|
||||
// disqualifies `required` properties (the `new()` constraint and required members do not
|
||||
// compose). EntryKey/TrackName/Artist therefore drop `required` here — the TrackEntity
|
||||
// side remains required, and TrackConverter assigns every field on the round-trip so an
|
||||
// empty default is never observable in production code paths.
|
||||
// compose). EntryKey/TrackName therefore drop `required` here — the TrackEntity side remains
|
||||
// required, and TrackConverter assigns every field on the round-trip so an empty default is
|
||||
// never observable in production code paths.
|
||||
//
|
||||
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields are read via Release?.X.
|
||||
public class TrackDto : BaseModel
|
||||
{
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
public long? ReleaseId { get; set; }
|
||||
public ReleaseDto? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-project track filter contract. Threaded alongside (never inside) the external
|
||||
/// <c>PagingParameters<T></c>, which cannot carry a where-clause. An instance with all
|
||||
/// properties null is equivalent to no filter — see <c>TrackFilter.IsEmpty</c>.
|
||||
/// </summary>
|
||||
public class TrackFilter
|
||||
{
|
||||
/// <summary>Free-text, case-insensitive LIKE across TrackName, Artist, and Album.</summary>
|
||||
public string? SearchText { get; set; }
|
||||
|
||||
/// <summary>Exact album match.</summary>
|
||||
public string? Album { get; set; }
|
||||
|
||||
/// <summary>Exact genre match.</summary>
|
||||
public string? Genre { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when no predicate is set. An empty filter must produce identical results to a null
|
||||
/// filter, so callers collapse it to null before querying.
|
||||
/// </summary>
|
||||
public bool IsEmpty =>
|
||||
string.IsNullOrWhiteSpace(SearchText)
|
||||
&& string.IsNullOrWhiteSpace(Album)
|
||||
&& string.IsNullOrWhiteSpace(Genre);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Wire contract for a stored waveform loudness profile. <see cref="Data"/> is the base64
|
||||
/// encoding of a <c>byte[BucketCount]</c>, each byte a peak-normalized loudness value in
|
||||
/// [0, 255] (the quantized form of a [0, 1] float). The frontend renders these as bar heights
|
||||
/// in the WaveformSeeker. A track with no stored profile yields no DTO (the frontend falls
|
||||
/// back to a flat seekbar), so this type never represents "absent" — only a present profile.
|
||||
/// </summary>
|
||||
public class WaveformProfileDto
|
||||
{
|
||||
public int BucketCount { get; set; }
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Per-track waveform profile status for the CMS PreProcessing panel. Tells admins which tracks
|
||||
/// already carry a stored loudness profile and which predate the WaveformSeeker feature and need
|
||||
/// backfilling. <see cref="HasProfile"/> is the existence check; <see cref="EntryKey"/> is the
|
||||
/// vault key used to trigger generation for a missing profile.
|
||||
/// </summary>
|
||||
public class WaveformStatusDto
|
||||
{
|
||||
public long TrackId { get; set; }
|
||||
public string EntryKey { get; set; } = string.Empty;
|
||||
public string TrackName { get; set; } = string.Empty;
|
||||
public bool HasProfile { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using Models.Entities;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
// The release-cardinal half of the normalized track schema (Phase 8 §8.0). One ReleaseEntity is
|
||||
// shared by every track on the same album; track-cardinal data stays on TrackEntity, which points
|
||||
// back here via a nullable ReleaseId (singles and loose tracks have no release context).
|
||||
//
|
||||
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses declare it
|
||||
// explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
public class ReleaseEntity : BaseEntity, IEntity
|
||||
{
|
||||
public required string Title { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public ReleaseType ReleaseType { get; set; } = ReleaseType.Single;
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public ICollection<TrackEntity> Tracks { get; set; } = new List<TrackEntity>();
|
||||
}
|
||||
@@ -5,14 +5,16 @@ namespace DeepDrftModels.Entities;
|
||||
// Inherits Id, CreatedAt, UpdatedAt, IsDeleted from BaseEntity (Cerebellum.BlazorBlocks.Models).
|
||||
// BaseEntity ships the audit columns but does not declare IEntity itself, so subclasses
|
||||
// declare it explicitly to satisfy the generic constraints on Repository<>/Manager<>/etc.
|
||||
//
|
||||
// Track-cardinal data only (Phase 8 §8.0). Release-cardinal fields (Artist, Album→Title, Genre,
|
||||
// ReleaseDate, ImagePath, ReleaseType, CreatedByUserId) live on ReleaseEntity, reached via the
|
||||
// nullable Release navigation; ReleaseId is null for singles and loose tracks.
|
||||
public class TrackEntity : BaseEntity, IEntity
|
||||
{
|
||||
public required string EntryKey { get; set; }
|
||||
public required string TrackName { get; set; }
|
||||
public required string Artist { get; set; }
|
||||
public string? Album { get; set; }
|
||||
public string? Genre { get; set; }
|
||||
public DateOnly? ReleaseDate { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public long? CreatedByUserId { get; set; }
|
||||
public string? OriginalFileName { get; set; }
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
public long? ReleaseId { get; set; }
|
||||
public ReleaseEntity? Release { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>The commercial release format of a track's parent release.</summary>
|
||||
public enum ReleaseType
|
||||
{
|
||||
Single,
|
||||
EP,
|
||||
Album
|
||||
}
|
||||
@@ -10,15 +10,21 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `TracksView.razor` (track gallery with pagination/sorting), `TrackDetail.razor` (single-track detail view with cover, metadata, play affordance). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date).
|
||||
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints).
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
|
||||
- `AppNavLink.razor`: Nav link with active-page highlight.
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (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.
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
@@ -27,9 +33,10 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, offset)` → `Stream`.
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
- `ViewModels/`: Component state.
|
||||
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
|
||||
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
|
||||
- `Common/`: Shared utilities.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `DDIcons.cs`: Hand-rolled SVG icons (gas-lamp lit/unlit for dark mode toggle).
|
||||
@@ -54,23 +61,33 @@ Both are configured with JSON serializer settings (case-insensitive property mat
|
||||
### Implementation
|
||||
- `AudioPlayerService` (abstract base): Lifecycle. Stores current track, playback state, volume. `SelectTrack` throws `NotSupportedException` (buffered path is dead); derived classes override `SelectTrackStreaming`.
|
||||
- `StreamingAudioPlayerService` (production): Constructor takes `TrackMediaClient`, `AudioInteropService`, logger. `SelectTrackStreaming`:
|
||||
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: 0)`.
|
||||
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
|
||||
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId)`, which returns a response object including `ContentType` (e.g., `audio/wav`, `audio/mpeg`, `audio/flac`).
|
||||
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` (JS interop call with format hint).
|
||||
3. TypeScript `StreamDecoder` is format-agnostic; delegates format-specific header parsing and chunked decoding to the appropriate `IFormatDecoder` implementation (e.g., `WavFormatDecoder` for WAV, TBD MP3/FLAC decoders for other formats). Decoder parses header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
4. `PlaybackScheduler` schedules buffers on Web Audio `AudioContext`.
|
||||
5. Playback starts as soon as a configurable min buffer count is queued.
|
||||
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, offset: byteOffset)`. Server's `WavOffsetService` synthesises a new 44-byte WAV header and streams from the offset. Player tears down and re-initialises decoder for the new stream.
|
||||
6. **Seek beyond buffer**: if seek target is past the decoded range, `Seek(position)` calls `TrackMediaClient.GetAudioStreamAsync(trackId, byteOffset)` with a file-absolute byte offset. Client sends `Range: bytes={offset}-`; server responds 206 with raw bytes (same format as original file); decoder retains the parsed header and feeds the continuation directly into the decode pipeline.
|
||||
|
||||
### Interop bridge
|
||||
- `AudioInteropService.CreatePlayerAsync` polls `DeepDrftAudio.isReady()` before proceeding; `index.ts` sets `ready = true` after attaching the API to `window`. This guards against slow WASM boot / cache misses.
|
||||
- `AudioInteropService.ProcessStreamingChunkAsync(chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(chunk)` and awaits the Promise.
|
||||
- `AudioInteropService.ProcessStreamingChunkAsync(contentType, chunk)` calls JS `window.DeepDrftAudio.processStreamingChunk(contentType, chunk)` and awaits the Promise. The `contentType` parameter is passed through to the format-decoder factory.
|
||||
- `AudioInteropService` also manages callback registrations for progress (fired by `PlaybackScheduler`), end-of-playback (fired by `PlaybackScheduler`), and spectrum data (fired by `SpectrumAnalyzer`). Each callback is a `DotNetObjectReference` to a delegate.
|
||||
|
||||
### Format decoders (TypeScript)
|
||||
New modules in `DeepDrftPublic/Interop/audio/`:
|
||||
|
||||
- `IFormatDecoder.ts`: Interface. Defines contract for format-specific decoders: `parseHeader(chunk, offset)` → header metadata; `decodeChunk(chunk, offset)` → `AudioBuffer`; `getAlignedSegmentSize(chunk, offset, rawData?)` → frame-aligned segment boundary (optional `rawData` parameter for format-specific frame-boundary scanning).
|
||||
- `WavFormatDecoder.ts`: Concrete WAV implementation (active). Parses RIFF/WAVE structure, fmt and data chunks. All WAV-specific byte-parsing logic lives here. Exported as the default WAV decoder.
|
||||
- `Mp3FormatDecoder.ts`: Concrete MP3 implementation (implemented, not yet wired). Implements `IFormatDecoder` for MP3: ID3v2 skip, MPEG Layer III frame-sync + header decode (MPEG1/2/2.5), Xing/Info/VBRI VBR-header detection (frame count + 100-entry TOC for seek), CBR frame-aligned segment sizing, VBR TOC-interpolation seek (`calculateByteOffset`), zero-copy `wrapSegment` (raw MP3 frames are self-contained). CBR sub-frame tail guard prevents over-read.
|
||||
- `FlacFormatDecoder.ts`: Concrete FLAC implementation (implemented, not yet wired). Implements `IFormatDecoder` for FLAC: scans all metadata blocks (STREAMINFO mandatory, SEEKTABLE optional), extracts 20-bit sample rate / 3-bit channels / 5-bit bitsPerSample / 36-bit total-samples from bit-packed STREAMINFO, builds 38-byte synthetic STREAMINFO block for per-segment wrapping, binary-search SEEKTABLE for seek. `wrapSegment` prepends `fLaC + STREAMINFO` to each audio segment so `decodeAudioData` sees a valid FLAC stream. `getAlignedSegmentSize` scans backward through peek bytes for the `0xFF/(0xF8|0xF9)` FLAC frame sync so each segment ends on a real frame boundary.
|
||||
|
||||
`StreamDecoder.ts` remains the orchestrator — it accepts the first chunk, selects the right format decoder via factory (based on `contentType`), peeks candidate bytes before calling `getAlignedSegmentSize` (non-destructive read), passes them as `rawData`, and uses zero-copy `subarray` for the actual segment. It delegates all format-specific work to the decoder and chains subsequent chunks through the same decoder instance. `Mp3FormatDecoder` and `FlacFormatDecoder` are implemented modules but not yet wired into `AudioPlayer.createFormatDecoder` factory (Wave 3 pending).
|
||||
|
||||
### Component integration
|
||||
- `AudioPlayerProvider.razor` is the cascading host. It injects `IStreamingPlayerService` (resolved to `StreamingAudioPlayerService` in DI), stores it in a cascade with `IsFixed="true"`, and keeps it alive across navigation.
|
||||
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
|
||||
- `AudioPlayerBar.razor` is the dock UI. It cascades the player, binds buttons to `Play()` / `Pause()` / `Seek()` / `SetVolume()`, and displays current time / duration / progress bar. Minimize-state mutations (`Expand`, `ToggleMinimized`, `Close`) all route through a private `SetMinimized(bool value)` mutator, which guards no-ops, fires the `OnMinimized` callback, and calls `StateHasChanged()`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render itself when the cascade updates.
|
||||
- `SpectrumVisualizer.razor` calls `AudioInteropService.GetSpectrumData()` on a timer, receives bar heights, renders via MudBlazor `MudChart` or custom canvas.
|
||||
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and clears `_selectedTrack` when `!PlayerService.IsLoaded` (covers Stop, Unload, and end-of-track).
|
||||
- `TracksView.razor` injects `TracksViewModel` + cascaded `IStreamingPlayerService`. `PlayTrack(track)` calls `PlayerService.SelectTrackStreaming(track)`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` and calls `StateHasChanged()` unconditionally on any state change, ensuring the gallery correctly reflects play/pause/track-change transitions. Active-track state is derived from `PlayerService.CurrentTrack` and `PlayerService.IsPlaying` (no local `_selectedTrack` field).
|
||||
|
||||
## Dark-mode plumbing
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ public class TrackClient
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
string? sortColumn = null,
|
||||
bool sortDescending = false)
|
||||
bool sortDescending = false,
|
||||
string? searchText = null,
|
||||
string? album = null,
|
||||
string? genre = null)
|
||||
{
|
||||
var queryArgs = new Dictionary<string, string?>(){
|
||||
["page"] = pageNumber.ToString(),
|
||||
@@ -33,6 +36,15 @@ public class TrackClient
|
||||
if (sortDescending)
|
||||
queryArgs["sortDescending"] = "true";
|
||||
|
||||
if (!string.IsNullOrEmpty(searchText))
|
||||
queryArgs["q"] = searchText;
|
||||
|
||||
if (!string.IsNullOrEmpty(album))
|
||||
queryArgs["album"] = album;
|
||||
|
||||
if (!string.IsNullOrEmpty(genre))
|
||||
queryArgs["genre"] = genre;
|
||||
|
||||
string query = QueryString.Create(queryArgs).ToString();
|
||||
|
||||
var response = await _http.GetAsync($"api/track/page{query}");
|
||||
@@ -50,4 +62,84 @@ public class TrackClient
|
||||
? ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged)
|
||||
: ApiResult<PagedResult<TrackDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a random track from the public library. A 404 means the library is empty — a valid
|
||||
/// state, not an error — so it returns a pass result with a null value. Any other non-success
|
||||
/// status is a genuine failure.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<TrackDto?>> GetRandom()
|
||||
{
|
||||
var response = await _http.GetAsync("api/track/random");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return ApiResult<TrackDto?>.CreatePassResult(null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<TrackDto?>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return track is not null
|
||||
? ApiResult<TrackDto?>.CreatePassResult(track)
|
||||
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<List<ReleaseDto>>> GetAlbums()
|
||||
{
|
||||
var response = await _http.GetAsync("api/track/albums");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<List<ReleaseDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var releases = JsonSerializer.Deserialize<List<ReleaseDto>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return releases is not null
|
||||
? ApiResult<List<ReleaseDto>>.CreatePassResult(releases)
|
||||
: ApiResult<List<ReleaseDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
|
||||
{
|
||||
var response = await _http.GetAsync("api/track/genres");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<List<GenreSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var genres = JsonSerializer.Deserialize<List<GenreSummaryDto>>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return genres is not null
|
||||
? ApiResult<List<GenreSummaryDto>>.CreatePassResult(genres)
|
||||
: ApiResult<List<GenreSummaryDto>>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
|
||||
public async Task<ApiResult<TrackDto>> GetTrack(string entryKey)
|
||||
{
|
||||
var response = await _http.GetAsync($"api/track/meta/by-key/{Uri.EscapeDataString(entryKey)}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ApiResult<TrackDto>.CreateFailResult($"HTTP {(int)response.StatusCode}");
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var track = JsonSerializer.Deserialize<TrackDto>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return track is not null
|
||||
? ApiResult<TrackDto>.CreatePassResult(track)
|
||||
: ApiResult<TrackDto>.CreateFailResult("Failed to deserialize response");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NetBlocks.Models;
|
||||
|
||||
@@ -8,15 +12,26 @@ public class TrackMediaResponse : IDisposable
|
||||
public Stream Stream { get; }
|
||||
public long ContentLength { get; }
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength)
|
||||
/// <summary>
|
||||
/// The response media type (e.g. "audio/wav", "audio/mpeg"). Drives format-decoder
|
||||
/// selection on the JS side. Falls back to "audio/wav" when the server omits the header.
|
||||
/// </summary>
|
||||
public string ContentType { get; }
|
||||
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public TrackMediaResponse(Stream stream, long contentLength, string contentType, HttpResponseMessage response)
|
||||
{
|
||||
Stream = stream;
|
||||
ContentLength = contentLength;
|
||||
ContentType = contentType;
|
||||
_response = response;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stream?.Dispose();
|
||||
_response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +45,12 @@ public class TrackMediaClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
|
||||
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
|
||||
/// navigation or seek-replacement aborts the in-flight server connection rather
|
||||
/// than leaving the server draining bytes into a dead socket.
|
||||
/// Fetches the WAV stream for a track via an HTTP Range request starting at a
|
||||
/// file-absolute byte offset. <paramref name="byteOffset"/> is the position from
|
||||
/// the start of the file on disk (including the WAV header) — callers seeking into
|
||||
/// audio data must add the header size themselves. The cancellation token aborts
|
||||
/// the in-flight server connection rather than leaving the server draining bytes
|
||||
/// into a dead socket.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||
string trackId,
|
||||
@@ -42,23 +59,60 @@ public class TrackMediaClient
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build URL with optional offset parameter
|
||||
var url = byteOffset > 0
|
||||
? $"api/track/{trackId}?offset={byteOffset}"
|
||||
: $"api/track/{trackId}";
|
||||
// Same URL for every seek — only the Range header differs. byteOffset 0 is
|
||||
// not special-cased: "bytes=0-" requests the whole file from the start.
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"api/track/{trackId}");
|
||||
request.Headers.Range = new RangeHeaderValue(byteOffset, null);
|
||||
|
||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
||||
var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
// Default to WAV when the server omits the header — the only format shipping
|
||||
// today — so the JS factory always receives a usable media type.
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/wav";
|
||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
||||
// TrackMediaResponse takes ownership of both stream and response;
|
||||
// do NOT dispose response here — the caller disposes via TrackMediaResponse.Dispose().
|
||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength, contentType, response));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ApiResult<TrackMediaResponse>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a track's stored waveform loudness profile. A 404 means no profile is stored
|
||||
/// (existing tracks predate profiling, or computation failed at upload); callers treat that
|
||||
/// as "render a flat seekbar" rather than an error, so it surfaces as a fail result with a
|
||||
/// stable message rather than throwing.
|
||||
/// </summary>
|
||||
public async Task<ApiResult<WaveformProfileDto>> GetWaveformProfileAsync(string trackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync($"api/track/{trackId}/waveform", cancellationToken);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return ApiResult<WaveformProfileDto>.CreateFailResult("No waveform profile available");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var profile = await response.Content.ReadFromJsonAsync<WaveformProfileDto>();
|
||||
if (profile is null)
|
||||
{
|
||||
return ApiResult<WaveformProfileDto>.CreateFailResult("Waveform profile response was empty");
|
||||
}
|
||||
|
||||
return ApiResult<WaveformProfileDto>.CreatePassResult(profile);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ApiResult<WaveformProfileDto>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +1,46 @@
|
||||
@if (_isMinimized)
|
||||
{
|
||||
<div class="minimized-dock d-flex align-center justify-center"
|
||||
@onclick="@ToggleMinimized">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ExpandLess"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
Class="minimized-button"
|
||||
OnClick="@ToggleMinimized"/>
|
||||
<div class="minimized-dock">
|
||||
<LevelMeterFab OnClick="@ToggleMinimized" />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
else
|
||||
{
|
||||
<div class="player-outer-container d-flex flex-column">
|
||||
<div class="@PlayerModeClass d-flex flex-column" @ref="_playerRoot">
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="player-inner-container">
|
||||
<div class="player-backdrop pa-3">
|
||||
|
||||
@if (_isDesktop)
|
||||
{
|
||||
@* Desktop Layout *@
|
||||
<div class="d-flex align-center gap-3">
|
||||
<div class="controls-left d-flex flex-column align-center gap-2">
|
||||
<div class="d-flex align-center gap-1">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
</div>
|
||||
<MudPaper Elevation="8" Class="player-surface pa-3">
|
||||
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div class="seekbar-flex mx-3"
|
||||
@onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
<div class="player-layout">
|
||||
<PlayerTransportZone IsLoaded="IsLoaded"
|
||||
CanPlay="CanPlay"
|
||||
IsLoading="IsLoading"
|
||||
IsStreaming="IsStreaming"
|
||||
LoadProgress="LoadProgress"
|
||||
DisplayTime="DisplayTime"
|
||||
Duration="Duration"
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"
|
||||
Class="transport-zone"/>
|
||||
|
||||
<div class="volume-right">
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
<div class="meta-zone">
|
||||
<TrackMetaLabel Track="CurrentTrack"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Mobile Layout *@
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<PlayerControls IsPlaying="IsPlaying"
|
||||
IsLoaded="IsLoaded"
|
||||
TogglePlayPause="@TogglePlayPause"
|
||||
Stop="@Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</div>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="Duration"/>
|
||||
<VolumeControls Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-grow-1">
|
||||
<div @onpointerdown="OnSeekStart"
|
||||
@onpointerup="@(() => OnSeekEnd(_seekPosition))"
|
||||
@onpointerleave="@(async () => { if (_isSeeking) await OnSeekEnd(_seekPosition); })">
|
||||
<MudSlider T="double"
|
||||
Min="0"
|
||||
Max="@(Duration ?? 0D)"
|
||||
Step="0.1"
|
||||
Value="@DisplayTime"
|
||||
ValueChanged="@OnSeekChange"
|
||||
Immediate="true"
|
||||
Disabled="@(!CanSeek)"/>
|
||||
</div>
|
||||
<SpectrumVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Control Buttons - positioned absolutely like original *@
|
||||
<div class="player-controls d-flex align-center justify-center gap-1">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Minimize"
|
||||
Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="@ToggleMinimized"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="@Close"/>
|
||||
<PlayerSeekZone OnSeekStart="@OnSeekStart"
|
||||
OnSeekEnd="@OnSeekEnd"
|
||||
OnSeekChange="@OnSeekChange"
|
||||
Class="seek-zone"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Minimize / close — positioned absolutely top-right *@
|
||||
@if (!Fixed)
|
||||
{
|
||||
<PlayerWindowControls OnMinimize="@ToggleMinimized" OnClose="@Close"/>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
@@ -124,7 +53,4 @@ else
|
||||
</MudAlert>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Spacer to prevent content overlap *@
|
||||
<div class="player-spacer"></div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using MudBlazor;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[Inject] public required IBrowserViewportService BrowserViewportService { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
|
||||
[Parameter] public EventCallback<bool> OnMinimized { get; set; }
|
||||
|
||||
[Inject] private IJSRuntime JsRuntime { get; set; } = default!;
|
||||
|
||||
private bool _isMinimized = true;
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private bool _isDesktop = true;
|
||||
private Guid _viewportSubscriptionId;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
|
||||
// Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
|
||||
// spacer reserves its space. We mirror this element's live height into a CSS
|
||||
// var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static
|
||||
// value, because the player reflows across breakpoints and grows with the
|
||||
// error banner.
|
||||
private ElementReference _playerRoot;
|
||||
private IJSObjectReference? _spacerModule;
|
||||
private bool _spacerObserved;
|
||||
|
||||
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
|
||||
private bool IsLoading => PlayerService?.IsLoading ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// A track is staged when it has been selected as the current track but not yet loaded into
|
||||
/// the audio context (the embed's pre-gesture state). The first play click loads + plays it.
|
||||
/// </summary>
|
||||
private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };
|
||||
|
||||
/// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
|
||||
private bool CanPlay => IsLoaded || IsStaged;
|
||||
private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
|
||||
private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
|
||||
private bool IsPlaying => PlayerService?.IsPlaying ?? false;
|
||||
private bool IsPaused => PlayerService?.IsPaused ?? false;
|
||||
private double? Duration => PlayerService?.Duration;
|
||||
private TrackDto? CurrentTrack => PlayerService?.CurrentTrack;
|
||||
private double Volume => PlayerService?.Volume ?? 0;
|
||||
private double LoadProgress => PlayerService?.LoadProgress ?? 0;
|
||||
private string? ErrorMessage => PlayerService?.ErrorMessage;
|
||||
@@ -32,15 +52,15 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
|
||||
|
||||
/// <summary>
|
||||
/// Seek is enabled once track is loaded AND duration is known (from WAV header).
|
||||
/// This allows seeking even during streaming, including seeking beyond buffered content.
|
||||
/// </summary>
|
||||
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
|
||||
private string PlayerModeClass => Fixed ? "player-fixed" : "player-dock";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Fixed)
|
||||
{
|
||||
_isMinimized = false;
|
||||
}
|
||||
|
||||
// PlayerService is cascaded by AudioPlayerProvider; once it arrives,
|
||||
// wire our track-selection handler. The provider owns OnStateChanged —
|
||||
// we intentionally do NOT wrap or replace it. Because the cascade is
|
||||
@@ -60,18 +80,71 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task Expand()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_isMinimized)
|
||||
// Only the docked, expanded shape needs a spacer: the Fixed embed is
|
||||
// already in normal flow, and the minimized FAB floats clear of content.
|
||||
// Toggle the observer on the minimized/expanded transition only — the
|
||||
// ResizeObserver itself handles every size change in between.
|
||||
var shouldObserve = !_isMinimized && !Fixed;
|
||||
if (shouldObserve == _spacerObserved) return;
|
||||
|
||||
var module = await GetSpacerModuleAsync();
|
||||
if (module is null) return;
|
||||
|
||||
if (shouldObserve)
|
||||
await module.InvokeVoidAsync("observe", _playerRoot);
|
||||
else
|
||||
await module.InvokeVoidAsync("unobserve");
|
||||
|
||||
_spacerObserved = shouldObserve;
|
||||
}
|
||||
|
||||
private async Task<IJSObjectReference?> GetSpacerModuleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_isMinimized = false;
|
||||
StateHasChanged();
|
||||
return _spacerModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/layout/spacer.js");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Module failed to load — the spacer falls back to 0px (no overlap
|
||||
// guard, but the player still works). Nothing actionable here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Expand() => await SetMinimized(false);
|
||||
|
||||
/// <summary>
|
||||
/// The single assignment site for <see cref="_isMinimized"/>. Guards no-op transitions,
|
||||
/// fires <see cref="OnMinimized"/> so MainLayout's spacer class stays in sync, and renders
|
||||
/// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path.
|
||||
/// The <c>Fixed</c> branch in OnParametersSet intentionally bypasses this — it is a
|
||||
/// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer.
|
||||
/// </summary>
|
||||
private async Task SetMinimized(bool value)
|
||||
{
|
||||
if (_isMinimized == value) return;
|
||||
|
||||
_isMinimized = value;
|
||||
if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task TogglePlayPause()
|
||||
{
|
||||
if (PlayerService == null) return;
|
||||
|
||||
// Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on
|
||||
// the first play click — the user gesture the browser requires before audio can start.
|
||||
if (IsStaged)
|
||||
{
|
||||
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
|
||||
return;
|
||||
}
|
||||
|
||||
await PlayerService.TogglePlayPause();
|
||||
}
|
||||
|
||||
@@ -112,11 +185,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
PlayerService?.ClearError();
|
||||
}
|
||||
|
||||
private void ToggleMinimized()
|
||||
{
|
||||
_isMinimized = !_isMinimized;
|
||||
StateHasChanged();
|
||||
}
|
||||
private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
@@ -125,35 +194,9 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
await PlayerService.Unload();
|
||||
}
|
||||
|
||||
if (!_isMinimized)
|
||||
{
|
||||
_isMinimized = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
await SetMinimized(true);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
var breakpoint = await BrowserViewportService.GetCurrentBreakpointAsync();
|
||||
_isDesktop = breakpoint >= Breakpoint.Sm;
|
||||
|
||||
_viewportSubscriptionId = Guid.NewGuid();
|
||||
await BrowserViewportService.SubscribeAsync(
|
||||
_viewportSubscriptionId,
|
||||
args =>
|
||||
{
|
||||
_isDesktop = args.Breakpoint >= Breakpoint.Sm;
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
new ResizeOptions { NotifyOnBreakpointOnly = true },
|
||||
fireImmediately: true);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
@@ -161,6 +204,20 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
await BrowserViewportService.UnsubscribeAsync(_viewportSubscriptionId);
|
||||
|
||||
if (_spacerModule is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Clear the var so a torn-down player can't strand a spacer height.
|
||||
await _spacerModule.InvokeVoidAsync("unobserve");
|
||||
await _spacerModule.DisposeAsync();
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Runtime already gone (navigation/teardown) — nothing to clean up.
|
||||
}
|
||||
_spacerModule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/* Preserve key visual styles while simplifying layout */
|
||||
/* Geometry, positioning, and animation only.
|
||||
Colour, surface, and elevation come from MudBlazor theme props. */
|
||||
|
||||
/* Player outer container - fixed positioning */
|
||||
.player-outer-container {
|
||||
/* Fixed dock to the viewport bottom */
|
||||
.player-dock {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@@ -11,166 +12,118 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Player inner container */
|
||||
.player-inner-container {
|
||||
padding: 1rem;
|
||||
padding-bottom: 1.5rem;
|
||||
.player-fixed {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1200;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Custom backdrop blur container */
|
||||
.player-backdrop {
|
||||
::deep .player-inner-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* The visible surface is a MudPaper; scoped CSS only sets geometry + a hairline accent */
|
||||
::deep .player-surface {
|
||||
position: relative;
|
||||
background: var(--deepdrft-theme-background-gray);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 1rem;
|
||||
border: 2px solid var(--deepdrft-theme-primary);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
color: inherit;
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--mud-palette-primary);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron frame effect */
|
||||
:global(.deepdrft-theme-light) .player-backdrop {
|
||||
background: color-mix(in srgb, var(--charleston-cream) 92%, transparent);
|
||||
border: 2px solid var(--charleston-iron);
|
||||
box-shadow: 0 4px 20px color-mix(in srgb, var(--charleston-iron) 20%, transparent),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--charleston-iron) 5%, transparent);
|
||||
color: var(--charleston-iron);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Warm sunset glow effect */
|
||||
:global(.deepdrft-theme-dark) .player-backdrop {
|
||||
background: color-mix(in srgb, var(--lowcountry-night) 88%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--lowcountry-coral) 50%, transparent);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 25%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 15%, transparent),
|
||||
0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
color: var(--lowcountry-moonlight);
|
||||
}
|
||||
|
||||
/* Control buttons positioning */
|
||||
.player-controls {
|
||||
/* Minimize / close cluster, pinned top-right of the surface */
|
||||
::deep .player-surface .player-window-controls {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Minimized floating dock with gradient */
|
||||
/* Minimized floating dock — positioning + hover only; colour from MudFab */
|
||||
.minimized-dock {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 60px;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
z-index: 1300;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg,
|
||||
var(--deepdrft-theme-primary) 0%,
|
||||
var(--deepdrft-theme-secondary) 50%,
|
||||
var(--deepdrft-theme-tertiary) 100%
|
||||
);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border: 2px solid var(--deepdrft-theme-secondary);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Charleston (Light Mode) - Iron dock */
|
||||
:global(.deepdrft-theme-light) .minimized-dock {
|
||||
background: linear-gradient(135deg, var(--charleston-iron) 0%, var(--charleston-rose) 50%, var(--charleston-gold) 100%);
|
||||
border: 2px solid var(--charleston-iron);
|
||||
box-shadow: 0 4px 15px color-mix(in srgb, var(--charleston-iron) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Lowcountry (Dark Mode) - Warm sunset dock */
|
||||
:global(.deepdrft-theme-dark) .minimized-dock {
|
||||
background: linear-gradient(135deg, var(--lowcountry-coral) 0%, var(--lowcountry-twilight) 50%, var(--lowcountry-gold) 100%);
|
||||
border: 2px solid color-mix(in srgb, var(--lowcountry-coral) 60%, transparent);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--lowcountry-coral) 40%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--lowcountry-twilight) 20%, transparent);
|
||||
}
|
||||
|
||||
.minimized-dock:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-light) .minimized-dock:hover {
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--charleston-iron) 50%, transparent);
|
||||
}
|
||||
|
||||
:global(.deepdrft-theme-dark) .minimized-dock:hover {
|
||||
box-shadow: 0 0 30px color-mix(in srgb, var(--lowcountry-coral) 50%, transparent),
|
||||
0 0 50px color-mix(in srgb, var(--lowcountry-twilight) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Minimized button styles */
|
||||
.minimized-button {
|
||||
border-radius: 50% !important;
|
||||
background: transparent !important;
|
||||
color: white !important;
|
||||
transition: all 0.3s ease !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
}
|
||||
|
||||
/* Spacer to prevent content overlap */
|
||||
.player-spacer {
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Essential layout adjustments only */
|
||||
.controls-left {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.seekbar-visualizer-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seekbar-flex {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.volume-right {
|
||||
/*min-width: 140px;*/
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.minimized-dock {
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.minimized-button {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
|
||||
::deep .player-surface {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.player-inner-container {
|
||||
padding: 0.75rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Unified responsive player layout — CSS Grid with named areas redefined per breakpoint.
|
||||
The metadata (meta-zone) detaches from the waveform (seek-zone) in the mid band, which a
|
||||
flex order-swap can't express, so each of the four zones is placed by grid-area and the three
|
||||
shapes are pure template-area swaps — no runtime breakpoint subscription. min-width:0 on the
|
||||
shrinkable centre zones lets the title truncate and the waveform shrink instead of overflowing. */
|
||||
.player-layout {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/*padding-right: 2.5rem; !* clear the abs-positioned PlayerWindowControls *!*/
|
||||
}
|
||||
|
||||
::deep .transport-zone { grid-area: transport; }
|
||||
::deep .meta-zone { grid-area: meta; min-width: 0; }
|
||||
::deep .seek-zone { grid-area: waveform; min-width: 0; }
|
||||
::deep .volume-zone { grid-area: volume; }
|
||||
|
||||
/* Wide (>= 900px): single row — transport and volume flank the centre column; the metadata
|
||||
sits directly under the waveform (transport/volume span both rows, centred). */
|
||||
@media (min-width: 900px) {
|
||||
.player-layout {
|
||||
grid-template-columns: auto minmax(360px, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"transport waveform volume"
|
||||
"transport meta .";
|
||||
}
|
||||
|
||||
.player-backdrop {
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Mid (600–900px): metadata rides the top row between transport and volume; the waveform gets
|
||||
the whole bottom row to itself rather than being squeezed beside the metadata. */
|
||||
@media (min-width: 600px) and (max-width: 899.98px) {
|
||||
.player-layout {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"transport meta volume"
|
||||
"waveform waveform waveform";
|
||||
}
|
||||
|
||||
.player-spacer {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
/* Narrow (< 600px): transport + volume share the top row; waveform then metadata stack full-width
|
||||
below — the most compressed shape. */
|
||||
@media (min-width: 420px) and (max-width: 599.98px) {
|
||||
.player-layout {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"transport . volume"
|
||||
"waveform waveform waveform"
|
||||
"meta meta meta";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Very Narrow (< 400px): transport + volume share the top row; waveform then metadata stack full-width
|
||||
below — the most compressed shape. */
|
||||
@media (max-width: 419.98px) {
|
||||
.player-layout {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"transport . volume"
|
||||
"waveform waveform waveform"
|
||||
"meta meta meta";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<button class="lmf-fab-btn" type="button" @onclick="OnClick">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="lmf-icon" aria-hidden="true">
|
||||
<defs>
|
||||
<!-- Vertical gradient anchored to viewBox (userSpaceOnUse), bottom -> top.
|
||||
y1=24 (bottom) is the green end; y2=0 (top) is the orange end. -->
|
||||
<linearGradient id="lmf-grad-@(IdSuffix)"
|
||||
gradientUnits="userSpaceOnUse" x1="0" y1="24" x2="0" y2="0">
|
||||
<stop offset="0%" stop-color="#2a883b" />
|
||||
<stop offset="17%" stop-color="#2a883b" />
|
||||
<stop offset="23%" stop-color="#2ECC71" />
|
||||
<stop offset="47%" stop-color="#2ECC71" />
|
||||
<stop offset="53%" stop-color="#F4C430" />
|
||||
<stop offset="72%" stop-color="#F4C430" />
|
||||
<stop offset="78%" stop-color="#FF6B35" />
|
||||
<stop offset="100%" stop-color="#FF6B35" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- The note silhouette, used to clip the fill rect. -->
|
||||
<clipPath id="lmf-clip-@(IdSuffix)">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<!-- Always-on dim silhouette: the idle look and the unfilled remainder. -->
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"
|
||||
fill="rgba(255,255,255,0.25)" />
|
||||
|
||||
<!-- Clipped fill: a full-width rect revealed through the note shape. -->
|
||||
<g clip-path="url(#lmf-clip-@(IdSuffix))">
|
||||
<rect class="lmf-fill-rect"
|
||||
x="0" width="24"
|
||||
y="@FillY" height="@FillH"
|
||||
fill="url(#lmf-grad-@(IdSuffix))" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -0,0 +1,146 @@
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class LevelMeterFab : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnClick { get; set; }
|
||||
|
||||
// Calibration window for true RMS dBFS (AnalyserNode.getFloatTimeDomainData).
|
||||
// Floor/ceiling define the dB window; the linear map across this 57 dB range places
|
||||
// a steady dance track (~-14 dBFS) at ~81% fill (upper yellow) and a hot drop
|
||||
// (~-6 dBFS) at ~95% (deep orange). Attack/release coefficients are by-ear tuning values.
|
||||
private const double FloorDb = -60.0; // fill = 0%; below this is near-silence (true RMS dBFS)
|
||||
private const double CeilingDb = -3.0; // fill = 100%; hot peaks on commercial masters (true RMS dBFS)
|
||||
private const double SilenceFloorDb = -80.0; // matches the analyzer's normalization window
|
||||
|
||||
private const double AttackCoefficient = 0.5; // fast rise toward a louder reading
|
||||
private const double ReleaseCoefficient = 0.12; // slow decay so the column doesn't strobe
|
||||
|
||||
private readonly string _instanceId = Guid.NewGuid().ToString();
|
||||
private bool _isAnimating;
|
||||
private string? _playerId;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
|
||||
private double _smoothedDb = SilenceFloorDb;
|
||||
private double _fillPercent; // 0..100, the sole render state
|
||||
|
||||
private string IdSuffix => _instanceId.Replace("-", "");
|
||||
|
||||
private double FillHeight => 24.0 * (_fillPercent / 100.0);
|
||||
private string FillY => (24.0 - FillHeight).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||
private string FillH => FillHeight.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// The cascade is IsFixed, so the provider's re-renders do NOT re-run
|
||||
// OnParametersSet here. Subscribe to the multicast StateChanged side-channel
|
||||
// so animation state stays correct independent of parent re-renders —
|
||||
// notably when the bar minimizes while a track is already playing.
|
||||
if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
|
||||
PlayerService.StateChanged += OnPlayerStateChanged;
|
||||
_subscribedService = PlayerService;
|
||||
}
|
||||
|
||||
if (_playerId == null && PlayerService is AudioPlayerService baseService)
|
||||
{
|
||||
_playerId = baseService.PlayerId;
|
||||
}
|
||||
|
||||
await UpdateAnimationState();
|
||||
}
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(async () =>
|
||||
{
|
||||
if (_playerId == null && PlayerService is AudioPlayerService baseService)
|
||||
{
|
||||
_playerId = baseService.PlayerId;
|
||||
}
|
||||
await UpdateAnimationState();
|
||||
});
|
||||
|
||||
private async Task UpdateAnimationState()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_playerId) || PlayerService == null) return;
|
||||
|
||||
var shouldAnimate = PlayerService.IsPlaying;
|
||||
|
||||
if (shouldAnimate && !_isAnimating)
|
||||
{
|
||||
await StartAnimation();
|
||||
}
|
||||
else if (!shouldAnimate && _isAnimating)
|
||||
{
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAnimation()
|
||||
{
|
||||
if (_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = true;
|
||||
_smoothedDb = SilenceFloorDb;
|
||||
_fillPercent = 0;
|
||||
await AudioInterop.StartLevelAnimationAsync(_playerId, _instanceId, OnLevelData);
|
||||
}
|
||||
|
||||
private async Task StopAnimation()
|
||||
{
|
||||
if (!_isAnimating || string.IsNullOrEmpty(_playerId)) return;
|
||||
|
||||
_isAnimating = false;
|
||||
await AudioInterop.StopLevelAnimationAsync(_playerId, _instanceId);
|
||||
|
||||
// Drop the column to empty so only the dim silhouette remains.
|
||||
if (_fillPercent != 0)
|
||||
{
|
||||
_fillPercent = 0;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
_smoothedDb = SilenceFloorDb;
|
||||
}
|
||||
|
||||
private Task OnLevelData(double db)
|
||||
{
|
||||
// db is true RMS dBFS from getFloatTimeDomainData; -Infinity on silence.
|
||||
var instantDb = double.IsNegativeInfinity(db) || double.IsNaN(db)
|
||||
? SilenceFloorDb
|
||||
: Math.Max(db, SilenceFloorDb);
|
||||
|
||||
// Attack-fast / release-slow envelope so the column doesn't strobe at 30fps.
|
||||
var coeff = instantDb > _smoothedDb ? AttackCoefficient : ReleaseCoefficient;
|
||||
_smoothedDb += (instantDb - _smoothedDb) * coeff;
|
||||
|
||||
// Linear map of smoothed dB onto a 0-100 fill across the [floor, ceiling] window.
|
||||
var next = Math.Clamp((_smoothedDb - FloorDb) / (CeilingDb - FloorDb) * 100.0, 0.0, 100.0);
|
||||
|
||||
// Re-render only on a meaningful change to avoid 30fps churn over sub-pixel deltas.
|
||||
if (Math.Abs(next - _fillPercent) >= 0.5)
|
||||
{
|
||||
_fillPercent = next;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_subscribedService != null)
|
||||
{
|
||||
_subscribedService.StateChanged -= OnPlayerStateChanged;
|
||||
_subscribedService = null;
|
||||
}
|
||||
await StopAnimation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Circular FAB matching MudFab Size.Large (56px) with MudBlazor primary palette */
|
||||
.lmf-fab-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background-color: var(--mud-palette-primary);
|
||||
color: var(--mud-palette-primary-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0px 3px 5px -1px rgba(0,0,0,.2),
|
||||
0px 6px 10px 0px rgba(0,0,0,.14),
|
||||
0px 1px 18px 0px rgba(0,0,0,.12);
|
||||
transition: box-shadow 150ms ease-out;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lmf-fab-btn:hover {
|
||||
box-shadow: 0px 7px 8px -4px rgba(0,0,0,.2),
|
||||
0px 12px 17px 2px rgba(0,0,0,.14),
|
||||
0px 5px 22px 4px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.lmf-fab-btn:focus-visible {
|
||||
outline: 2px solid var(--mud-palette-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Fill motion is driven by C#-computed SVG geometry, not CSS — no transition here. */
|
||||
.lmf-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
<div class="player-buttons">
|
||||
<MudIconButton Icon="@GetPlayIcon()"
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<PlayStateIcon Size="Size.Large"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@TogglePlayPause"
|
||||
Disabled="!IsLoaded"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@Stop"
|
||||
Disabled="!IsLoaded"/>
|
||||
</div>
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@Stop"
|
||||
Disabled="!IsLoaded"/>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class PlayerControls : ComponentBase
|
||||
{
|
||||
[Parameter] public required bool IsPlaying { get; set; }
|
||||
[Parameter] public required bool IsLoaded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the play button is enabled. Distinct from <see cref="IsLoaded"/> so a staged-but-
|
||||
/// unloaded track (embed pre-gesture state) can still be played: the click loads it. Stop stays
|
||||
/// gated on <see cref="IsLoaded"/>.
|
||||
/// </summary>
|
||||
[Parameter] public bool CanPlay { get; set; }
|
||||
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public required EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public required EventCallback Stop { get; set; }
|
||||
private string GetPlayIcon()
|
||||
{
|
||||
return IsPlaying ? Icons.Material.Filled.Pause : Icons.Material.Filled.PlayArrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/* PlayerControls Component Styles */
|
||||
|
||||
/* Button spacing and alignment */
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<MudStack Row="false" Spacing="1" Class="@Class">
|
||||
<WaveformSeeker OnSeekStart="OnSeekStart"
|
||||
OnSeekChange="OnSeekChange"
|
||||
OnSeekEnd="OnSeekEnd"
|
||||
Class="seek-waveform"/>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
/// <summary>
|
||||
/// Centre zone of the player: the <see cref="WaveformSeeker"/>. The seeker owns the pointer-gesture
|
||||
/// seek logic and reads playback state off the cascaded player service directly; this zone just
|
||||
/// forwards the seek callbacks up to <see cref="AudioPlayerBar"/> (whose wiring is unchanged).
|
||||
/// The now-playing metadata (<see cref="TrackMetaLabel"/>) is a sibling zone in the grid, not nested
|
||||
/// here, so the responsive layouts can place it independently of the waveform.
|
||||
/// </summary>
|
||||
public partial class PlayerSeekZone : ComponentBase
|
||||
{
|
||||
[Parameter] public EventCallback OnSeekStart { get; set; }
|
||||
[Parameter] public EventCallback<double> OnSeekEnd { get; set; }
|
||||
[Parameter] public EventCallback<double> OnSeekChange { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<MudStack Row="false" AlignItems="AlignItems.Center" Spacing="1" Class="@Class">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<PlayerControls IsLoaded="IsLoaded"
|
||||
CanPlay="CanPlay"
|
||||
Fixed="Fixed"
|
||||
TogglePlayPause="TogglePlayPause"
|
||||
Stop="Stop"/>
|
||||
@if (IsLoading && !IsStreaming)
|
||||
{
|
||||
<MudProgressCircular Color="Color.Tertiary"
|
||||
Size="Size.Small"
|
||||
Max="1D"
|
||||
Value="@LoadProgress"
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</MudStack>
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class PlayerTransportZone : ComponentBase
|
||||
{
|
||||
[Parameter] public bool IsLoaded { get; set; }
|
||||
[Parameter] public bool CanPlay { get; set; }
|
||||
[Parameter] public bool IsLoading { get; set; }
|
||||
[Parameter] public bool IsStreaming { get; set; }
|
||||
[Parameter] public double LoadProgress { get; set; }
|
||||
[Parameter] public double DisplayTime { get; set; }
|
||||
[Parameter] public double? Duration { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; } = false;
|
||||
[Parameter] public EventCallback TogglePlayPause { get; set; }
|
||||
[Parameter] public EventCallback Stop { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* Stable minimum width so the transport cluster doesn't reflow */
|
||||
.transport-zone {
|
||||
min-width: 180px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@namespace DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="player-window-controls">
|
||||
<MudIconButton Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="OnMinimize">
|
||||
—
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Color="Color.Secondary"
|
||||
Size="Size.Small"
|
||||
OnClick="OnClose"/>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
|
||||
public partial class PlayerWindowControls : ComponentBase
|
||||
{
|
||||
[Parameter] public EventCallback OnMinimize { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user