31 Commits

Author SHA1 Message Date
daniel b4cda76114 fix: Cover Art Upload Enable State
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m1s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m0s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m23s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m28s
2026-06-10 19:21:39 -04:00
daniel 38529a962a docs(plan): add Phase 1.1 Extended WAV format support
Track the two EXTENSIBLE WAV sub-cases scoped out of the
WAVE_FORMAT_EXTENSIBLE PCM fix: non-PCM (IEEE Float) SubFormats and
padded 24-in-32 containers.
2026-06-10 15:31:25 -04:00
daniel d2a9475ba2 Merge branch 'extensible-wav-support' into dev 2026-06-10 15:27:47 -04:00
daniel e84823be39 docs: update AudioProcessor notes for EXTENSIBLE-PCM WAV support 2026-06-10 15:26:50 -04:00
daniel 6c602170a9 fix(audio): guard EXTENSIBLE fmt OOB read on truncated buffer; document padded-container gap 2026-06-10 15:24:31 -04:00
daniel 88ac5b2c88 fix(audio): support WAVE_FORMAT_EXTENSIBLE PCM WAVs, normalizing them to standard PCM on upload 2026-06-10 15:20:34 -04:00
daniel 0f5eaa42b5 Merge branch 'soft-delete-fix' into dev 2026-06-10 14:32:50 -04:00
daniel f0185587f7 fix(data): route all TrackRepository queries through soft-delete-filtered Query 2026-06-10 14:32:31 -04:00
daniel 0a5ddfdad8 Merge branch 'seek-load-race' into dev 2026-06-10 14:31:48 -04:00
daniel 8b94a5fdf7 fix: assign seek CTS synchronously and guard load finally to stop seek/load race 2026-06-10 14:30:12 -04:00
daniel fb27918ed6 fix: guard LoadTrackStreaming OCE catch with loadCts identity so an in-flight seek isn't clobbered mid-load 2026-06-10 14:22:35 -04:00
daniel 691d904273 Merge branch 'track-new-image' into dev 2026-06-10 14:18:44 -04:00
daniel ded5a3e5eb feat(manager): add optional cover art upload to Add Track form 2026-06-10 14:14:35 -04:00
daniel f25d0f624f Merge branch 'seek-hardening' into dev 2026-06-10 13:25:17 -04:00
daniel 43f54cb950 Merge branch 'p2-w1-filter-views' into dev 2026-06-10 12:43:33 -04:00
daniel f40940b957 fix: guard SeekBeyondBuffer OCE catch with when(seekCts.IsCancellationRequested) so timeout OCEs fall through to error handler 2026-06-10 11:08:54 -04:00
daniel 10256677ac docs: close Phase 2.2/2.3 — move to COMPLETED.md, update DeepDrftPublic proxy CLAUDE.md 2026-06-10 10:58:16 -04:00
daniel 6fe7663667 fix: harden seek — timeout no longer swallowed as cancel, rapid seek-on-seek no longer clears active seek flag 2026-06-10 10:55:49 -04:00
daniel 5cae83b9ed feat: add search/album/genre filtering and /albums + /genres browse pages 2026-06-10 10:54:56 -04:00
daniel d9b92e0703 Merge branch 'seek-cancel-fix' into dev 2026-06-10 09:03:11 -04:00
daniel 0fd1977353 fix: silence false error log when streaming is cancelled during seek 2026-06-10 09:01:59 -04:00
daniel 1071ba7374 docs: bring CONTEXT.md §4 current — Phase 4 complete, iframe player landed 2026-06-09 21:58:24 -04:00
daniel 79a015f60a docs: update CLAUDE.md files to reflect Range header seek, remove WavOffsetService references 2026-06-09 07:41:38 -04:00
daniel 0bd7e6904d Merge branch 'p4-w2-retire-offset' into dev 2026-06-09 07:37:51 -04:00
daniel f602eb9772 chore: remove WavOffsetService and ?offset= seek path, superseded by Range header (Phase 4.1) 2026-06-09 07:30:36 -04:00
daniel b372bee365 Merge branch 'stream-now-button' into dev 2026-06-09 07:25:25 -04:00
daniel fad3635fa1 Merge branch 'p4-w1-range-streaming' into dev 2026-06-09 07:19:26 -04:00
daniel 561f4a500a docs: close Phase 4.1 and 4.2 — move to COMPLETED.md 2026-06-09 07:07:57 -04:00
daniel 9be35e5a58 refactor: extract StreamNowButton component shared by hero and nav menu 2026-06-09 07:00:37 -04:00
daniel aaa9f732ae feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client
- API: enableRangeProcessing true on no-offset FileStream path
- Proxy: transparent Range relay, forwards 206/416/Content-Range verbatim
- TrackMediaClient: Range: bytes=X- replaces ?offset=X; response disposed via TrackMediaResponse
- StreamDecoder: reinitializeForRangeContinuation retains wavHeader, counts raw PCM against 206 Content-Length
- AudioPlayer: seekBeyondBuffer adds headerSize for file-absolute offset; duration guard prevents continuation overwriting full-track duration
- StreamingAudioPlayerService: seek guard corrected to >= 0 (file-absolute offset contract)
2026-06-09 07:00:35 -04:00
daniel 5c3c3c3d0c docs(plan): commit Phase 4.1 to Option A1 (Range headers, custom decoder)
Record the design-gate decision for HTTP Range support: Range headers in
the JS fetch retaining the AudioBuffer decoder, rejecting MediaElement
(loses early-playback) and synthesized-header-over-Range (breaks caching
invariant). Add per-file shape, acceptance criteria, and the file-absolute
offset constraint. Tighten 4.2 — disk-streaming already done on the
default path; only the legacy offset branch remains.
2026-06-09 06:33:29 -04:00
49 changed files with 1658 additions and 764 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
@@ -70,7 +70,7 @@ The player is not fetch-then-play:
2. `StreamingAudioPlayerService` reads in adaptive 1664 KB chunks, pushes each via `AudioInteropService.processStreamingChunk`.
3. TypeScript `StreamDecoder` parses WAV header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}?offset={byteOffset}`. Server's `WavOffsetService` block-aligns offset, synthesises a fresh 44-byte WAV header, streams `[new header][data from offset]`. Player tears down and re-initialises decoder for the new stream.
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}` with `Range: bytes={byteOffset}-`. Server streams raw bytes from that file-absolute offset with a `206 Partial Content` response. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
+51
View File
@@ -6,6 +6,57 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 2.2 + 2.3 — Album/genre views and gallery search/filter
**Status:** Fully landed on 2026-06-10.
- **What:** Free-text search (`?q=`) across TrackName/Artist/Album via `EF.Functions.ILike` (Postgres, case-insensitive); album/genre exact-match filtering (`?album=`, `?genre=`); new `/albums` browsing page (grid of album cards with cover art and track counts, linking to filtered gallery); new `/genres` browsing page (genre list with counts, linking to filtered gallery); search bar with 400ms debounce and filter-pill dismiss on `TracksView`. Nav updated with Albums and Genres links.
- **Architecture:** Filter is threaded as a separate `TrackFilter` DTO alongside `PagingParameters<T>` (which is external and cannot carry a where-clause). Repository has new `GetPagedFilteredAsync`, `GetDistinctAlbumsAsync`, `GetDistinctGenresAsync` methods. `PersistentComponentState` restore on `TracksView` is skipped when filter params are active. `ClearFilter` preserves `SearchText` (only clears album/genre pill).
- **New types:** `TrackFilter`, `AlbumSummaryDto`, `GenreSummaryDto` in `DeepDrftModels/DTOs/`.
- **Tests:** `TrackFilterQueryTests` in `DeepDrftTests` — 4 in-memory cases plus 1 Postgres-gated `ILike` case (skip when `DEEPDRFT_TEST_PG` env var absent).
---
## Phase 4.1 — HTTP Range + CDN caching
**Status:** Fully landed on 2026-06-09 (implementation complete, all acceptance criteria met, merged to dev branch `p4-w1-range-streaming`).
- **What:** Today's `?offset=` query parameter defeats HTTP caching — a CDN sees `?offset=1234567` as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. Move the player's transport to standard HTTP `Range` headers against one canonical URL.
- **Why it matters:** Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
- **Chosen approach (design pass 2026-06-09): Option A1 — Range headers in the JS fetch, keep the custom `AudioBuffer` decoder.** Rejected Option B (`MediaElementAudioSourceNode`): it surrenders early-playback (the `minBuffersForPlayback` start-as-soon-as-buffered behaviour, a listed quality feature) and forces a redesign of the waveform-seek and early-play UX, while delivering no caching benefit beyond what the HTTP layer already gives. Also rejected A2 (synthesised header delivered over Range): keeping `WavOffsetService` on the hot path means each `bytes=X-` request produces a distinct synthesised prefix that can't share cache lineage with the canonical `bytes=0-` object, defeating half the caching win. A1 makes the cached object the *real file*, so every Range request is a true sub-range of one entity. Key enabling insight: `StreamDecoder` already synthesises a per-segment 44-byte header internally for every `decodeAudioData` call (`createWavFile`), so a Range continuation only needs to *retain* the parsed `WavHeader` and feed raw PCM — it does not need a header in the network stream.
- **Shape (implementation direction):**
- **Server (`DeepDrftAPI/Controllers/TrackController.cs` ~L407):** flip `enableRangeProcessing: false → true` on the no-offset seekable `FileStream` path; ASP.NET Core slices natively and emits `206` + `Content-Range`. Leave the `?offset=` / `WavOffsetService` branch reachable but off the player hot path — its removal is a clean follow-up commit, not part of this change.
- **Proxy (`DeepDrftPublic/Controllers/TrackProxyController.cs` ~L175):** forward the incoming `Range` request header upstream; pass through upstream status (`206`/`200`/`416`) and the `Content-Range` / `Accept-Ranges` / `Content-Length` response headers verbatim. The proxy is a transparent relay — it does **not** slice the (non-seekable) upstream stream. Keep `ResponseHeadersRead` + `RegisterForDispose`.
- **Client transport (`DeepDrftPublic.Client/Clients/TrackMediaClient`):** send `Range: bytes={byteOffset}-` instead of the `?offset=` query param (`byteOffset == 0``bytes=0-`, single code path). Confirm `TrackMediaResponse.ContentLength` carries the 206 remaining-length for continuations and full length for the initial request.
- **JS decoder (`StreamDecoder.ts` — the real work):** add a continuation mode. Replace `reinitializeForOffset` (which nulls `wavHeader` and re-parses) with a `reinitializeForRangeContinuation(remainingByteLength)` that **retains** the parsed `WavHeader`, resets `rawChunks`/`totalRawBytes`/`processedBytes`/`streamComplete`, and routes incoming bytes straight to `addRawData` (the existing `if (!this.wavHeader)` branch already does this when the header is set). Add an `isContinuation` flag so `updateStreamCompleteFlag()` uses `totalRawBytes` **without** the `+ headerSize` addend on continuations. `createWavFile`, the decode pipeline, and the spectrum/level tap are all unchanged.
- **`AudioPlayer.ts` / `index.ts`:** keep the public `reinitializeFromOffset` interop name (so `AudioInteropService` and the C# caller are untouched); internally call the continuation reinit. C# `StreamingAudioPlayerService.SeekBeyondBuffer` is otherwise unchanged.
- **Acceptance criteria:**
1. Initial load sends `Range: bytes=0-`; server responds `206`/`200` with `Accept-Ranges: bytes`; time-to-first-audio unchanged (early playback after `minBuffersForPlayback`).
2. Seek-beyond-buffer sends `Range: bytes=X-` (block-aligned, file-absolute X) with **no `?offset=` anywhere**; server responds `206` + `Content-Range`; audio resumes with no click/pop and no header bytes leaking into PCM.
3. Displayed total duration is unchanged across a seek (original full-track duration, not remaining-segment).
4. A track seeked-near-end then played out fires the end callback exactly once (continuation `streamComplete` math correct).
5. Spectrum visualiser and `LevelMeterFab` behave identically pre/post on a loud master (3 dBFS).
6. Same-URL invariant: two different-offset requests hit an identical URL differing only in the `Range` header (verifiable in the network panel; live CDN cache-hit verification is out of scope — no CDN in dev).
7. No `MediaElement` introduced; the `AudioBufferSourceNode` graph remains the playback path.
- **Constraints (non-obvious):**
- **Range offset is file-absolute, not audio-relative.** The old `?offset=` contract was audio-data-relative (`WavOffsetService` added `HeaderSize` server-side). The Range offset must be `header.headerSize + blockAlignedAudioOffset`. Omitting `headerSize` lands the seek ~44 bytes early — audible click + position drift. **Most likely bug; verify first.**
- Only the *continuation* skips header parse; the initial `bytes=0-` response still flows through `tryParseHeader` unchanged. Don't let the continuation flag bleed into initial load.
- Proxy must pass `Accept-Ranges` / `Content-Range` (and a `416`) through verbatim — stripping them blinds the browser and any future CDN.
- A1 preserves the multi-format (1.2) seam: the decoder stays the format integration point; the "retain format, skip header, treat bytes as frame data" pattern generalises (frame-boundary alignment differs per format). Add no new WAV-specific coupling in the transport/proxy layers beyond what already exists.
---
## Phase 4.2 — Server-side stream from disk (no buffer materialisation)
**Status:** Resolved as a consequence of Phase 4.1 landing on 2026-06-09. No separate implementation required.
- **What:** The no-offset path **already** streams from disk — `TrackController` (~L390) takes `mediaStream.Stream` (a `FileStream` from `LoadResourceStreamAsync`), reads `streamLength` from `.Length`, and hands ownership to `File(...)`; no `LoadResourceAsync` buffer materialisation on the default path. The remaining buffer materialisation is **only** the legacy `?offset=` branch (~L414): `GetAudioBinaryAsync` loads the full `AudioBinary` into memory because `WavOffsetService` reslices over the in-memory buffer.
- **Why it matters:** Scaling ceiling on the offset path specifically. Once 4.1 (A1) lands, the offset branch is off the player hot path, so its buffer cost stops mattering in practice.
- **Shape:** Resolved for the default path. The only outstanding work is retiring the offset branch entirely — which is the 4.1 follow-up commit (remove the `?offset=` server branch, `WavOffsetService`, and the now-unused `ConcatStream`). No separate work item beyond that cleanup.
- **Outcome:** With Phase 4.1 landing and Range headers replacing the `?offset=` query param as the transport mechanism, the offset branch is now definitively off the player's hot path. Buffer materialisation on that dormant code path is no longer a scaling concern. 4.2 is closed; the offset-branch cleanup is a follow-up housekeeping item, not a blocker.
---
## Phase 2.4 — Interactivity-gap loading guard on dead-during-prerender controls
**Status:** Fully landed on 2026-06-08 (implementation complete, reviewed and merged to dev).
+9 -11
View File
@@ -163,21 +163,19 @@ In dev, the host serves the original `.ts` sources at `/Interop/...` for source-
Recent commits (newest first):
- `docs: archive play-state icon normalization; update DeepDrftPublic.Client CLAUDE.md`
- `Consolidate play/pause icon logic into PlaybackIcons mapper and PlayStateIcon component`
- `Reflect real playback state on gallery cards and toggle pause/resume`
- `WASM State Fixes`
- `CMS Home autoredirect to /tracks`
- `WaveformSeeker Improvements` / WaveformSeeker waves 13
- (earlier: AudioPlayerBar responsive unification, CMS build-out, the two-app split, deployment infrastructure)
- `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)
Observations:
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. **The recent arc is player UX polish.** The latest wave of work is the WaveformSeeker (loudness-profile seekbar), AudioPlayerBar responsive unification, and play-state icon normalization (a single `PlaybackIcons` resolver + `PlayStateIcon` component, gallery cards reflecting real playback state with pause/resume). Presentation iteration on a stable streaming core.
3. **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.
4. **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.
5. **In flight (working tree, not yet committed):** an **embeddable iframe player** (`EmbedLayout.razor`, `FramePlayer.razor`, a new `ITrackDataService` seam) — a chrome-free single-track play surface for embedding off-site. Partial and not yet compiling; see `PLAN.md` "In-flight — Embeddable iframe player" for the open questions.
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.
---
+8 -9
View File
@@ -22,21 +22,20 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary) and imag
## 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])
@@ -161,7 +160,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
3. Register `FileDatabase` as singleton.
4. Ensure the `tracks` vault exists (type `MediaVaultType.Audio`, created on first boot if missing).
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
6. Register singletons: `WavOffsetService`, `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -252,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 \
+77 -69
View File
@@ -1,7 +1,6 @@
using DeepDrftAPI.Middleware;
using DeepDrftAPI.Models;
using DeepDrftAPI.Services;
using DeepDrftContent.Audio;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Services;
using DeepDrftContent.FileDatabase.Models;
@@ -17,7 +16,6 @@ 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;
@@ -32,7 +30,6 @@ public class TrackController : ControllerBase
public TrackController(
DeepDrftContent.TrackContentService trackContentService,
DeepDrftContent.FileDatabase.Services.FileDatabase fileDatabase,
WavOffsetService wavOffsetService,
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
@@ -40,7 +37,6 @@ public class TrackController : ControllerBase
{
_trackContentService = trackContentService;
_fileDatabase = fileDatabase;
_wavOffsetService = wavOffsetService;
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
@@ -51,17 +47,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";
@@ -72,6 +75,40 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/albums (unauthenticated)
// Distinct non-null albums with track counts and cover keys. Public browse data, same posture as
// GET api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
[HttpGet("albums")]
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
{
var result = await _sqlTrackService.GetDistinctAlbums(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.
@@ -352,84 +389,55 @@ public class TrackController : ControllerBase
// --- 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)
{
-2
View File
@@ -1,6 +1,5 @@
using DeepDrftAPI.Models;
using DeepDrftContent;
using DeepDrftContent.Audio;
using DeepDrftContent.Constants;
using DeepDrftContent.FileDatabase.Models;
using DeepDrftContent.FileDatabase.Services;
@@ -15,7 +14,6 @@ namespace DeepDrftAPI
public static Task ConfigureDomainServices(WebApplicationBuilder builder)
{
// Audio services
builder.Services.AddSingleton<WavOffsetService>();
builder.Services.AddSingleton<AudioProcessor>();
builder.Services.AddSingleton<TrackContentService>();
-318
View File
@@ -1,318 +0,0 @@
using System.Text;
namespace DeepDrftContent.Audio;
/// <summary>
/// Service for creating WAV audio streams starting from a byte offset.
/// Synthesizes a valid WAV header for the remaining audio data.
/// </summary>
public class WavOffsetService
{
/// <summary>
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
/// (format 3) and other formats are rejected at parse time so the
/// synthesized header here can safely assume PCM.
/// </summary>
public const short PcmFormat = 1;
/// <summary>
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
/// buffer — no copy of the audio payload is made.
/// </summary>
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
{
var format = ParseWavHeader(fullAudioBuffer);
if (format == null)
return null;
// Validate offset is within bounds and block-aligned
if (byteOffset < 0 || byteOffset >= format.DataSize)
return null;
// Align to block boundary for clean audio
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
var newDataSize = format.DataSize - alignedOffset;
if (newDataSize <= 0)
return null;
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
var sourcePosition = format.HeaderSize + alignedOffset;
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
var newDataSizeInt = (int)newDataSize;
var sourcePositionInt = (int)sourcePosition;
// Create new WAV header using the format reported by the parsed header.
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
// threading format.AudioFormat through keeps the header self-consistent
// and prevents drift if the validation contract is ever relaxed.
var newHeader = CreateWavHeader(format, newDataSizeInt);
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
// that the previous MemoryStream(capacity).Write(...) implementation forced.
var headerStream = new MemoryStream(newHeader, writable: false);
var dataStream = new MemoryStream(
fullAudioBuffer,
sourcePositionInt,
newDataSizeInt,
writable: false,
publiclyVisible: false);
return new ConcatStream(headerStream, dataStream);
}
/// <summary>
/// Parses the WAV header from a buffer to extract format information.
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
/// so downstream synthesis can safely assume PCM sample encoding.
/// </summary>
public WavFormat? ParseWavHeader(byte[] buffer)
{
if (buffer.Length < 44)
return null;
// Check RIFF header
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
if (riff != "RIFF")
return null;
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
if (wave != "WAVE")
return null;
// Variables to store parsed header info
int sampleRate = 0;
int channels = 0;
int bitsPerSample = 0;
int byteRate = 0;
int blockAlign = 0;
long dataSize = 0;
int headerSize = 0;
short audioFormat = 0;
bool foundFmt = false;
bool foundData = false;
// Find fmt and data chunks
int chunkOffset = 12;
while (chunkOffset < buffer.Length - 8)
{
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
if (chunkSize < 0)
return null;
if (chunkId == "fmt " && !foundFmt)
{
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
// AudioProcessor.FindChunk which also returns the first match.
if (chunkSize < 16)
return null;
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
// PCM only. Float32 WAVs were previously accepted here but the synthesized
// header below is PCM-shaped — accepting Float would produce a corrupt file
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
// non-PCM at upload time so this branch is defense in depth.
if (audioFormat != PcmFormat)
return null;
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
// Basic validation
if (channels < 1 || channels > 8)
return null;
foundFmt = true;
}
else if (chunkId == "data")
{
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
// values above int.MaxValue (files between 24 GB), then widen to long.
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
foundData = true;
}
// Move to next chunk with proper alignment (chunks are word-aligned)
chunkOffset += 8 + ((chunkSize + 1) & ~1);
// If we found both chunks, we're done
if (foundFmt && foundData)
break;
}
// Must have found both fmt and data chunks
if (!foundFmt || !foundData)
return null;
return new WavFormat(
AudioFormat: audioFormat,
SampleRate: sampleRate,
Channels: channels,
BitsPerSample: bitsPerSample,
ByteRate: byteRate,
BlockAlign: blockAlign,
DataSize: dataSize,
HeaderSize: headerSize
);
}
/// <summary>
/// Creates a standard 44-byte WAV header. The audio format code is taken from
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
/// </summary>
public byte[] CreateWavHeader(WavFormat format, int dataSize)
{
var header = new byte[44];
var fileSize = 36 + dataSize;
// RIFF header
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
// fmt chunk
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
// data chunk header
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
return header;
}
}
/// <summary>
/// WAV format information extracted from header.
/// </summary>
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
public record WavFormat(
short AudioFormat,
int SampleRate,
int Channels,
int BitsPerSample,
int ByteRate,
int BlockAlign,
long DataSize,
int HeaderSize
);
/// <summary>
/// Forward-only read stream over two underlying streams concatenated end-to-end.
/// Lets us serve "[synthesized header][slice of original buffer]" without
/// allocating a single contiguous buffer for the combined payload.
/// </summary>
internal sealed class ConcatStream : Stream
{
private readonly Stream _first;
private readonly Stream _second;
private readonly long _length;
private long _position;
public ConcatStream(Stream first, Stream second)
{
_first = first;
_second = second;
_length = first.Length + second.Length;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _length;
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
var total = 0;
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
// is full. Stream.Read is not required to fill the buffer in one call even
// when data is available (e.g. a future non-MemoryStream _first), so we must
// keep pulling until we get 0 before advancing to _second.
while (count > 0 && _position < _first.Length)
{
var read = _first.Read(buffer, offset, count);
if (read == 0) break;
total += read;
_position += read;
offset += read;
count -= read;
}
if (count > 0)
{
var read = _second.Read(buffer, offset, count);
total += read;
_position += read;
}
return total;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var total = 0;
// Same loop contract as Read() — exhaust _first before reading _second.
while (!buffer.IsEmpty && _position < _first.Length)
{
var read = await _first.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
total += read;
_position += read;
buffer = buffer[read..];
}
if (!buffer.IsEmpty)
{
var read = await _second.ReadAsync(buffer, cancellationToken);
total += read;
_position += read;
}
return total;
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_first.Dispose();
_second.Dispose();
}
base.Dispose(disposing);
}
}
+9 -23
View File
@@ -6,7 +6,7 @@ See the root `CLAUDE.md` for full architecture overview. This file covers what i
## One-line purpose
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), WAV stream-with-offset, audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
Binary-content domain logic. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), audio processing, and the content-side track service. Consumed by `DeepDrftContent` (the host) and `DeepDrftCli` (the admin CLI).
## Layout
@@ -17,8 +17,6 @@ DeepDrftContent.Services/
│ ├── Models/ # Data models, DTOs, enums
│ ├── Services/ # FileDatabase, MediaVault, IndexSystem, IndexWatcher
│ └── Utils/ # StructuralMap, StructuralSet, FileUtils
├── Audio/
│ └── WavOffsetService.cs # Byte offset → valid WAV stream
├── Processors/
│ └── AudioProcessor.cs # WAV file parsing, metadata extraction
├── Constants/
@@ -76,30 +74,19 @@ 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
`WavOffsetService.CreateOffsetStream(buffer, byteOffset)`:
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]`.
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.
**Block alignment is critical.** Do not bypass it. The WAV fmt chunk tells you the block size; use it.
## Audio processor
`AudioProcessor.ProcessWavFileAsync(filePath)`:
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).
1. Validates the RIFF/WAVE structure and format code.
2. Accepts standard PCM (audioFormat=1) and WAVE_FORMAT_EXTENSIBLE (audioFormat=0xFFFE) when the SubFormat GUID indicates PCM.
3. Normalizes EXTENSIBLE-PCM uploads to standard 44-byte PCM WAV before storing in the vault.
4. Parses the fmt and data chunks.
5. Extracts duration (sample count / sample rate) and bitrate (file size / duration).
6. Returns `AudioBinary` with all metadata.
7. **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.
PCM-only (both standard and EXTENSIBLE variants). Other formats (mp3, flac, aac, ogg, m4a) are listed in `MimeTypeExtensions` but not implemented. EXTENSIBLE with non-PCM SubFormats are rejected. The processor validates RIFF/WAVE/PCM structure — anything else is rejected.
## Image processor
@@ -141,7 +128,6 @@ Safety call to ensure the `tracks` vault exists (creates if missing). Called on
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
+100 -10
View File
@@ -28,10 +28,15 @@ public class AudioProcessor
{
var buffer = await File.ReadAllBytesAsync(filePath);
var wavInfo = ExtractWavMetadata(buffer);
// EXTENSIBLE-PCM is byte-compatible with standard PCM but carries a 40+ byte fmt chunk
// the streaming pipeline never expects. Normalize to a plain 44-byte PCM WAV at storage
// time so the vault only ever holds standard PCM and the client decode path stays unchanged.
var storedBuffer = wavInfo.IsExtensible ? NormalizeToStandardPcm(buffer, wavInfo) : buffer;
var parameters = new AudioBinaryParams(
Buffer: buffer,
Size: buffer.Length,
Buffer: storedBuffer,
Size: storedBuffer.Length,
Extension: ".wav",
Duration: wavInfo.Duration,
Bitrate: wavInfo.Bitrate
@@ -156,9 +161,35 @@ 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 only when its SubFormat GUID indicates PCM — the raw sample data is
// then byte-identical to standard PCM and we normalize it downstream.
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
if (audioFormat != 1)
var isExtensible = 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.
var subFormatPos = fmtChunkPos + 8 + 24;
if (buffer[subFormatPos] != 0x01 || buffer[subFormatPos + 1] != 0x00)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is not PCM" };
}
isExtensible = true;
}
else if (audioFormat != 1)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Only PCM format supported" };
}
@@ -170,11 +201,12 @@ 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
};
}
@@ -190,6 +222,17 @@ 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.
// Note: padded-container EXTENSIBLE (e.g. 24-bit valid in a 32-bit container) is not yet
// supported — the mismatched BlockAlign will cause ValidateAudioParameters to throw and fall
// back to defaults. This is an accepted gap as of this fix.
if (validation.IsExtensible)
{
bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
}
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
var bitrate = (int)((sampleRate * channels * bitsPerSample) / 1000);
@@ -201,7 +244,9 @@ public class AudioProcessor
Channels = channels,
BitsPerSample = bitsPerSample,
BlockAlign = blockAlign,
DataSize = (int)dataSize
DataSize = (int)dataSize,
DataChunkPos = validation.DataChunkPos,
IsExtensible = validation.IsExtensible
};
}
@@ -235,6 +280,48 @@ public class AudioProcessor
}
}
/// <summary>
/// Rebuilds an EXTENSIBLE-PCM WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1).
/// The sample bytes are copied verbatim — EXTENSIBLE-PCM data is byte-identical to standard PCM —
/// only the header is replaced, so the vault stores a format the streaming pipeline already handles.
/// </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 dataSize = Math.Min(metadata.DataSize, available);
const int headerSize = 44;
var result = new byte[headerSize + dataSize];
var blockAlign = (ushort)(metadata.Channels * (metadata.BitsPerSample / 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)metadata.BitsPerSample).CopyTo(result, 34);
// data chunk
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
Array.Copy(buffer, dataStart, result, headerSize, dataSize);
return result;
}
/// <summary>
/// Returns default WAV metadata for fallback scenarios
/// </summary>
@@ -305,6 +392,8 @@ public class AudioProcessor
public int BitsPerSample { get; set; }
public int BlockAlign { get; set; }
public int DataSize { get; set; }
public int DataChunkPos { get; set; }
public bool IsExtensible { get; set; }
}
/// <summary>
@@ -316,6 +405,7 @@ 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; }
}
}
+8 -1
View File
@@ -20,7 +20,14 @@ public interface ITrackService
/// </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>Distinct non-null albums with track counts and a representative cover key, album-ascending.</summary>
Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default);
/// <summary>Distinct non-null genres with track counts, genre-ascending.</summary>
Task<ResultContainer<List<GenreSummaryDto>>> GetDistinctGenres(CancellationToken cancellationToken = default);
Task<ResultContainer<TrackDto>> Create(TrackDto newTrack);
Task<ResultContainer<TrackDto>> Update(TrackDto track);
Task<Result> Delete(long id);
+96 -9
View File
@@ -1,49 +1,136 @@
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>
{
private readonly DeepDrftContext _context;
public TrackRepository(
DeepDrftContext context,
ILogger<Repository<DeepDrftContext, TrackEntity>> logger,
IDbExceptionClassifier? classifier = null)
: base(context, logger, classifier: classifier)
{
_context = context;
}
// Lookup by vault entry key. The base Repository<> only exposes id-based queries, so this
// queries the DbSet directly. Returns null on miss (service wraps in ResultContainer).
// uses Query (soft-delete filtered) rather than the raw DbSet.
public async Task<TrackEntity?> GetByEntryKeyAsync(string entryKey)
=> await _context.Tracks.FirstOrDefaultAsync(t => t.EntryKey == entryKey);
=> await Query.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. Queries the DbSet directly,
// mirroring GetByEntryKeyAsync, since the base Repository<> exposes only id-based reads.
// 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 _context.Tracks.CountAsync(cancellationToken);
var count = await Query.CountAsync(cancellationToken);
if (count == 0)
return null;
var index = Random.Shared.Next(count);
return await _context.Tracks
return await Query
.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)
{
IQueryable<TrackEntity> query = Query;
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. Album is nullable — ILike on a
// null column yields false, which is the desired "no match" behaviour.
var pattern = $"%{filter.SearchText}%";
query = query.Where(t =>
EF.Functions.ILike(t.TrackName, pattern)
|| EF.Functions.ILike(t.Artist, pattern)
|| (t.Album != null && EF.Functions.ILike(t.Album, pattern)));
}
if (!string.IsNullOrWhiteSpace(filter.Album))
query = query.Where(t => t.Album == filter.Album);
if (!string.IsNullOrWhiteSpace(filter.Genre))
query = query.Where(t => t.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,
};
}
// Distinct albums (non-null) with track counts and a representative cover key. The cover is the
// first non-null ImagePath in the group; GroupBy + projection keeps it a single round-trip.
public async Task<List<AlbumSummaryDto>> GetDistinctAlbumsAsync(CancellationToken ct = default)
=> await Query
.Where(t => t.Album != null)
.GroupBy(t => t.Album!)
.Select(g => new AlbumSummaryDto
{
Album = g.Key,
TrackCount = g.Count(),
CoverImageKey = g
.Where(t => t.ImagePath != null)
.OrderBy(t => t.Id)
.Select(t => t.ImagePath)
.FirstOrDefault(),
})
.OrderBy(a => a.Album)
.ToListAsync(ct);
// Distinct genres (non-null) with track counts.
public async Task<List<GenreSummaryDto>> GetDistinctGenresAsync(CancellationToken ct = default)
=> await Query
.Where(t => t.Genre != null)
.GroupBy(t => t.Genre!)
.Select(g => new GenreSummaryDto
{
Genre = g.Key,
TrackCount = g.Count(),
})
.OrderBy(g => g.Genre)
.ToListAsync(ct);
protected override void UpdateEntity(TrackEntity target, TrackEntity source)
{
base.UpdateEntity(target, source); // copies CreatedAt, UpdatedAt, IsDeleted
+35 -1
View File
@@ -97,6 +97,7 @@ public class TrackManager
int pageSize,
string? sortColumn,
bool sortDescending,
TrackFilter? filter = null,
CancellationToken cancellationToken = default)
{
try
@@ -117,7 +118,14 @@ public class TrackManager
}
};
var page = await Repository.GetPagedAsync(parameters);
// An all-null filter must produce identical results to no filter, so collapse it to
// null and take the unfiltered base path (preserves backward compatibility).
var effectiveFilter = filter is null || filter.IsEmpty ? null : filter;
var page = effectiveFilter is null
? await Repository.GetPagedAsync(parameters)
: await Repository.GetPagedFilteredAsync(parameters, effectiveFilter, cancellationToken);
var dtoPage = PagedResult<TrackDto>.From(page, page.Items.Select(TrackConverter.Convert));
return ResultContainer<PagedResult<TrackDto>>.CreatePassResult(dtoPage);
}
@@ -127,6 +135,32 @@ public class TrackManager
}
}
public async Task<ResultContainer<List<AlbumSummaryDto>>> GetDistinctAlbums(CancellationToken cancellationToken = default)
{
try
{
var albums = await Repository.GetDistinctAlbumsAsync(cancellationToken);
return ResultContainer<List<AlbumSummaryDto>>.CreatePassResult(albums);
}
catch (Exception e)
{
return ResultContainer<List<AlbumSummaryDto>>.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
@@ -29,6 +29,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 +95,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 +111,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 +172,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.
@@ -149,6 +206,26 @@
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);
Navigation.NavigateTo("/tracks");
return;
+14
View File
@@ -0,0 +1,14 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// One distinct album with its track count and a representative cover image key. Backs the
/// /albums browse grid.
/// </summary>
public class AlbumSummaryDto
{
public required string Album { get; set; }
public int TrackCount { get; set; }
/// <summary>ImagePath of the first track in the album that has one; null when none do.</summary>
public string? CoverImageKey { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace DeepDrftModels.DTOs;
/// <summary>One distinct genre with its track count. Backs the /genres browse list.</summary>
public class GenreSummaryDto
{
public required string Genre { get; set; }
public int TrackCount { get; set; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace DeepDrftModels.DTOs;
/// <summary>
/// Cross-project track filter contract. Threaded alongside (never inside) the external
/// <c>PagingParameters&lt;T&gt;</c>, which cannot carry a where-clause. An instance with all
/// properties null is equivalent to no filter — see <c>TrackFilter.IsEmpty</c>.
/// </summary>
public class TrackFilter
{
/// <summary>Free-text, case-insensitive LIKE across TrackName, Artist, and Album.</summary>
public string? SearchText { get; set; }
/// <summary>Exact album match.</summary>
public string? Album { get; set; }
/// <summary>Exact genre match.</summary>
public string? Genre { get; set; }
/// <summary>
/// True when no predicate is set. An empty filter must produce identical results to a null
/// filter, so callers collapse it to null before querying.
/// </summary>
public bool IsEmpty =>
string.IsNullOrWhiteSpace(SearchText)
&& string.IsNullOrWhiteSpace(Album)
&& string.IsNullOrWhiteSpace(Genre);
}
+4 -3
View File
@@ -17,6 +17,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `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.
@@ -32,7 +33,7 @@ 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`.
@@ -60,12 +61,12 @@ 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)`.
1. Calls `TrackMediaClient.GetAudioStreamAsync(trackId)`.
2. `StreamingAudioPlayerService.StreamAudioAsync` reads chunks (1664 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
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 PCM; decoder retains the parsed WAV 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.
+49 -1
View File
@@ -20,7 +20,10 @@ public class TrackClient
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
{
var queryArgs = new Dictionary<string, string?>(){
["page"] = pageNumber.ToString(),
@@ -33,6 +36,15 @@ public class TrackClient
if (sortDescending)
queryArgs["sortDescending"] = "true";
if (!string.IsNullOrEmpty(searchText))
queryArgs["q"] = searchText;
if (!string.IsNullOrEmpty(album))
queryArgs["album"] = album;
if (!string.IsNullOrEmpty(genre))
queryArgs["genre"] = genre;
string query = QueryString.Create(queryArgs).ToString();
var response = await _http.GetAsync($"api/track/page{query}");
@@ -77,6 +89,42 @@ public class TrackClient
: ApiResult<TrackDto?>.CreateFailResult("Failed to deserialize response");
}
public async Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
{
var response = await _http.GetAsync("api/track/albums");
if (!response.IsSuccessStatusCode)
return ApiResult<List<AlbumSummaryDto>>.CreateFailResult($"HTTP {(int)response.StatusCode}");
var json = await response.Content.ReadAsStringAsync();
var albums = JsonSerializer.Deserialize<List<AlbumSummaryDto>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return albums is not null
? ApiResult<List<AlbumSummaryDto>>.CreatePassResult(albums)
: ApiResult<List<AlbumSummaryDto>>.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)}");
@@ -1,4 +1,5 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using DeepDrftModels.DTOs;
using Microsoft.Extensions.DependencyInjection;
@@ -10,16 +11,19 @@ public class TrackMediaResponse : IDisposable
{
public Stream Stream { get; }
public long ContentLength { get; }
private readonly HttpResponseMessage _response;
public TrackMediaResponse(Stream stream, long contentLength)
public TrackMediaResponse(Stream stream, long contentLength, HttpResponseMessage response)
{
Stream = stream;
ContentLength = contentLength;
_response = response;
}
public void Dispose()
{
Stream?.Dispose();
_response?.Dispose();
}
}
@@ -33,10 +37,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,
@@ -45,19 +51,21 @@ 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;
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, response));
}
catch (Exception e)
{
@@ -5,7 +5,7 @@
We craft immersive electronic soundscapes &mdash; live; built from synthesizers, drum machines, and raw intention.
</p>
<div class="hero-actions @AnimClass">
<a class="btn-primary" href="/tracks">Start Streaming</a>
<StreamNowButton ButtonClass="btn-primary" ButtonLabel="Start Streaming" />
<a class="btn-ghost" href="/tracks">Browse Tracks</a>
</div>
@@ -75,51 +75,9 @@
animation-delay: 0.54s;
}
.btn-primary {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
background: var(--deepdrft-navy);
border: none;
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: background 0.25s, transform 0.2s;
display: inline-block;
}
.btn-primary:hover {
background: var(--deepdrft-green);
transform: translateY(-1px);
}
.btn-ghost {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-navy);
background: transparent;
border: 1px solid var(--deepdrft-border);
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: border-color 0.25s, color 0.25s;
display: inline-block;
}
.btn-ghost:hover { border-color: var(--deepdrft-navy); }
@media (max-width: 599px) {
.hero-actions {
flex-direction: column;
align-items: stretch;
}
.btn-primary,
.btn-ghost {
text-align: center;
}
}
@@ -0,0 +1,129 @@
@using DeepDrftPublic.Client.Services
<button type="button"
class="@ButtonClass"
disabled="@(_streamLoading || !RendererInfo.IsInteractive)"
aria-busy="@_streamLoading.ToString().ToLowerInvariant()"
@onclick="StreamNow">
@if (_findingTrack)
{
<span>@LoadingLabel</span>
}
else
{
<span>@ButtonLabel</span>
}
</button>
@if (_streamMessage is not null)
{
<p class="stream-now-message" role="status">@_streamMessage</p>
}
@implements IDisposable
@code {
[Parameter, EditorRequired] public required string ButtonClass { get; set; }
[Parameter, EditorRequired] public required string ButtonLabel { get; set; }
[Parameter] public string LoadingLabel { get; set; } = "Finding a track…";
[Parameter] public EventCallback OnStreamStarted { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
private bool _streamLoading;
private bool _findingTrack;
private string? _streamMessage;
private CancellationTokenSource? _messageCts;
private const string EmptyLibraryMessage = "No tracks yet — check back soon.";
private const string FetchFailedMessage = "Couldn't reach the library — try again.";
private async Task StreamNow()
{
// Re-entrancy guard: the button is disabled while loading, but guard in code too so a
// double-dispatch can never start two concurrent streams.
if (_streamLoading) return;
_streamLoading = true;
_findingTrack = true;
_streamMessage = null;
// Warm the AudioContext FIRST, inside the gesture's call stack and before the network
// await below. Safari only lets a suspended AudioContext resume while the originating
// user gesture is still active; awaiting GetRandomTrack() first would consume the gesture
// and leave playback silently refused. PlayerService is null only outside the
// AudioPlayerProvider cascade (it should always be present in the public layout).
var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask;
try
{
await warmTask;
var result = await TrackData.GetRandomTrack();
if (!result.Success)
{
ShowTransientMessage(FetchFailedMessage);
return;
}
if (result.Value is not { } track)
{
ShowTransientMessage(EmptyLibraryMessage);
return;
}
await OnStreamStarted.InvokeAsync();
// Track is found — flip only the label flag so the button reverts to
// its resting label before the stream begins, while _streamLoading stays true
// to keep the button disabled and the re-entrancy guard intact.
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
{
ShowTransientMessage(FetchFailedMessage);
}
finally
{
_streamLoading = false;
_findingTrack = false;
}
}
private void ShowTransientMessage(string message)
{
_streamMessage = message;
// Cancel any in-flight clear timer so the newest message gets its full display window.
_messageCts?.Cancel();
_messageCts?.Dispose();
_messageCts = new CancellationTokenSource();
var token = _messageCts.Token;
_ = ClearMessageAfterDelayAsync(token);
}
private async Task ClearMessageAfterDelayAsync(CancellationToken token)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(4), token);
}
catch (TaskCanceledException)
{
return;
}
_streamMessage = null;
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
_messageCts?.Cancel();
_messageCts?.Dispose();
}
}
@@ -0,0 +1,7 @@
.stream-now-message {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.12em;
color: var(--deepdrft-muted);
margin: 0.5rem 0 0;
}
+4 -145
View File
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@* Desktop Menu *@
@@ -16,20 +17,7 @@
</ul>
<div class="dd-nav-actions">
<button type="button"
class="dd-nav-cta"
disabled="@(_streamLoading || !RendererInfo.IsInteractive)"
aria-busy="@_streamLoading.ToString().ToLowerInvariant()"
@onclick="StreamNow">
@if (_findingTrack)
{
<span>Finding a track&#8230;</span>
}
else
{
<span>Stream Now &#9654;</span>
}
</button>
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" />
@* <button type="button" *@
@* class="dd-nav-toggle" *@
@* aria-label="Toggle dark mode" *@
@@ -38,11 +26,6 @@
@* @((MarkupString)DarkLightModeIconSvg) *@
@* </button> *@
</div>
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav>
</div>
@@ -78,147 +61,23 @@
</li>
}
<li>
<button type="button"
class="dd-nav-cta"
disabled="@(_streamLoading || !RendererInfo.IsInteractive)"
aria-busy="@_streamLoading.ToString().ToLowerInvariant()"
@onclick="StreamNowMobile">
@if (_findingTrack)
{
<span>Finding a track&#8230;</span>
}
else
{
<span>Stream Now &#9654;</span>
}
</button>
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;" OnStreamStarted="CloseMobileMenu" />
</li>
</ul>
}
@if (_streamMessage is not null)
{
<p class="dd-nav-stream-message" role="status">@_streamMessage</p>
}
</nav>
</div>
@implements IDisposable
@code {
[Inject] public required DarkModeCookieService DarkModeCookieService { get; set; }
[Inject] public required ITrackDataService TrackData { get; set; }
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
// Elevation is vestigial under the frosted-glass design but kept on the parameter
// surface so MainLayout's <DeepDrftMenu Elevation="..."> call site stays intact.
[Parameter] public int Elevation { get; set; }
[Parameter] public required bool IsDarkMode { get; set; }
[Parameter] public required EventCallback<bool> IsDarkModeChanged { get; set; }
private bool _mobileMenuOpen;
private bool _streamLoading;
private bool _findingTrack;
private string? _streamMessage;
private CancellationTokenSource? _messageCts;
private const string EmptyLibraryMessage = "No tracks yet — check back soon.";
private const string FetchFailedMessage = "Couldn't reach the library — try again.";
private Task StreamNow() => StreamNowCore(closeMobileMenu: false);
private Task StreamNowMobile() => StreamNowCore(closeMobileMenu: true);
private async Task StreamNowCore(bool closeMobileMenu)
{
// Re-entrancy guard: the button is disabled while loading, but guard in code too so a
// double-dispatch can never start two concurrent streams.
if (_streamLoading) return;
_streamLoading = true;
_findingTrack = true;
_streamMessage = null;
// Warm the AudioContext FIRST, inside the gesture's call stack and before the network
// await below. Safari only lets a suspended AudioContext resume while the originating
// user gesture is still active; awaiting GetRandomTrack() first would consume the gesture
// and leave playback silently refused. PlayerService is null only outside the
// AudioPlayerProvider cascade (it should always be present in the public layout).
var warmTask = PlayerService?.WarmAudioContext() ?? Task.CompletedTask;
try
{
await warmTask;
var result = await TrackData.GetRandomTrack();
if (!result.Success)
{
ShowTransientMessage(FetchFailedMessage);
return;
}
if (result.Value is not { } track)
{
ShowTransientMessage(EmptyLibraryMessage);
return;
}
if (closeMobileMenu)
_mobileMenuOpen = false;
// Track is found — flip only the label flag so the button reverts to
// "Stream Now ▶" before the stream begins, while _streamLoading stays true
// to keep the button disabled and the re-entrancy guard intact.
_findingTrack = false;
StateHasChanged();
if (PlayerService is not null)
await PlayerService.SelectTrackStreaming(track);
}
catch (Exception)
{
ShowTransientMessage(FetchFailedMessage);
}
finally
{
_streamLoading = false;
_findingTrack = false;
}
}
private void ShowTransientMessage(string message)
{
_streamMessage = message;
// Cancel any in-flight clear timer so the newest message gets its full display window.
_messageCts?.Cancel();
_messageCts?.Dispose();
_messageCts = new CancellationTokenSource();
var token = _messageCts.Token;
_ = ClearMessageAfterDelayAsync(token);
}
private async Task ClearMessageAfterDelayAsync(CancellationToken token)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(4), token);
}
catch (TaskCanceledException)
{
return;
}
_streamMessage = null;
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
_messageCts?.Cancel();
_messageCts?.Dispose();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -90,7 +90,7 @@
}
/* Stream Now CTA — square pill, navy on warm white */
.dd-nav-cta {
::deep .dd-nav-cta {
display: inline-block;
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
@@ -106,18 +106,18 @@
transition: background 0.25s ease;
}
.dd-nav-cta:hover,
.dd-nav-cta:focus-visible {
::deep .dd-nav-cta:hover,
::deep .dd-nav-cta:focus-visible {
background: var(--deepdrft-green);
}
.dd-nav-dark .dd-nav-cta {
.dd-nav-dark ::deep .dd-nav-cta {
color: var(--deepdrft-white);
background: var(--deepdrft-primary);
}
.dd-nav-dark .dd-nav-cta:hover,
.dd-nav-dark .dd-nav-cta:focus-visible {
.dd-nav-dark ::deep .dd-nav-cta:hover,
.dd-nav-dark ::deep .dd-nav-cta:focus-visible {
background: var(--deepdrft-senary);
}
@@ -207,7 +207,7 @@
padding: 0.6rem 0;
}
.dd-nav-links-mobile .dd-nav-cta {
.dd-nav-links-mobile ::deep .dd-nav-cta {
margin-top: 0.5rem;
text-align: center;
}
+5 -3
View File
@@ -13,9 +13,11 @@ public static class Pages
{
public static readonly List<PageRoute> MenuPages =
[
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
new() { Name = "Releases", Route = "/tracks", Icon = Icons.Material.Filled.LibraryMusic },
new() { Name = "Albums", Route = "/albums", Icon = Icons.Material.Filled.Album },
new() { Name = "Genres", Route = "/genres", Icon = Icons.Material.Filled.Category },
new() { Name = "Sessions", Route = "#", Icon = Icons.Material.Filled.Piano }, // TODO: placeholder until Sessions ships
new() { Name = "Mixes", Route = "#", Icon = Icons.Material.Filled.Album }, // TODO: placeholder until Mixes ships
];
public static readonly List<PageRoute> AllPages =
@@ -0,0 +1,63 @@
@page "/albums"
<PageTitle>DeepDrft Albums</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
@if (_loading)
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
</div>
</MudItem>
}
</MudGrid>
}
else if (_albums.Count == 0)
{
<div class="albums-empty">
<MudText Typo="Typo.h6">No albums yet</MudText>
</div>
}
else
{
<MudGrid Spacing="6" Justify="Justify.Center">
@foreach (var album in _albums)
{
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
<div class="album-card-center">
<div class="album-card"
role="button"
tabindex="0"
@onclick="@(() => OpenAlbum(album.Album))">
@if (!string.IsNullOrEmpty(album.CoverImageKey))
{
<div class="album-card-cover"
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
</div>
}
else
{
<div class="album-card-cover album-card-cover--fallback"></div>
}
<div class="album-card-body">
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
@album.Album
</MudText>
<MudText Typo="Typo.caption" Class="album-card-count">
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</div>
</div>
</MudItem>
}
</MudGrid>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class AlbumsView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<AlbumSummaryDto> _albums = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetAlbums();
if (result is { Success: true, Value: { } albums })
_albums = albums;
_loading = false;
}
private void OpenAlbum(string album)
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
}
@@ -0,0 +1,58 @@
.albums-view-container {
padding-top: 16px;
}
.album-card-center {
display: flex;
justify-content: center;
width: 100%;
}
.album-card {
display: flex;
flex-direction: column;
width: 200px;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
transition: transform 120ms ease;
}
.album-card:hover {
transform: translateY(-4px);
}
.album-card-cover {
width: 200px;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.album-card-cover--fallback {
background-color: var(--mud-palette-dark, #1a2238);
}
.album-card-body {
padding: 8px 4px 0 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
::deep .album-card-title {
font-weight: 600;
}
::deep .album-card-count {
opacity: 0.7;
}
.albums-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -0,0 +1,41 @@
@page "/genres"
<PageTitle>DeepDrft Genres</PageTitle>
<div>
<MudContainer MaxWidth="MaxWidth.Medium" Class="genres-view-container">
@if (_loading)
{
<div class="genres-list">
@foreach (var _ in Enumerable.Range(0, 8))
{
<MudSkeleton Height="48px" Width="100%" Class="mb-2"/>
}
</div>
}
else if (_genres.Count == 0)
{
<div class="genres-empty">
<MudText Typo="Typo.h6">No genres yet</MudText>
</div>
}
else
{
<MudList T="string" Class="genres-list">
@foreach (var genre in _genres)
{
<MudListItem T="string"
Icon="@Icons.Material.Filled.Category"
OnClick="@(() => OpenGenre(genre.Genre))">
<div class="genre-row">
<MudText Typo="Typo.subtitle1">@genre.Genre</MudText>
<MudText Typo="Typo.caption" Class="genre-count">
@genre.TrackCount @(genre.TrackCount == 1 ? "track" : "tracks")
</MudText>
</div>
</MudListItem>
}
</MudList>
}
</MudContainer>
</div>
@@ -0,0 +1,26 @@
using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Pages;
public partial class GenresView : ComponentBase
{
[Inject] public required ITrackDataService TrackData { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
private bool _loading = true;
private List<GenreSummaryDto> _genres = [];
protected override async Task OnInitializedAsync()
{
var result = await TrackData.GetGenres();
if (result is { Success: true, Value: { } genres })
_genres = genres;
_loading = false;
}
private void OpenGenre(string genre)
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
}
@@ -0,0 +1,28 @@
.genres-view-container {
padding-top: 16px;
}
/* genres-list rides on MudList, a child Razor component whose root Blazor isolation
does not scope-stamp; ::deep is required. */
::deep .genres-list {
width: 100%;
}
.genre-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
width: 100%;
}
/* genre-count rides on MudText (child Razor component); ::deep pierces into its output. */
::deep .genre-count {
opacity: 0.7;
}
.genres-empty {
display: flex;
justify-content: center;
padding: 48px 0;
}
@@ -5,6 +5,41 @@
<div>
<div class="tracks-view-container">
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
toggle's interactivity gate. *@
@if (RendererInfo.IsInteractive)
{
<div class="tracks-search-row">
<MudTextField T="string"
Value="@ViewModel.SearchText"
ValueChanged="@OnSearchInput"
Immediate="true"
DebounceInterval="400"
Placeholder="Search tracks, artists, albums"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Clearable="true"
Class="tracks-search-field"/>
</div>
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
{
<div class="tracks-filter-pills">
<MudChip T="string"
Color="Color.Tertiary"
Variant="Variant.Filled"
OnClose="@(_ => ClearFilter())">
@(ViewModel.FilterAlbum is not null
? $"Album: {ViewModel.FilterAlbum}"
: $"Genre: {ViewModel.FilterGenre}")
</MudChip>
</div>
}
}
@if (ViewModel.Page != null)
{
<div class="tracks-view-header">
@@ -13,8 +13,15 @@ public partial class TracksView : ComponentBase, IDisposable
[Inject] public required TracksViewModel ViewModel { get; set; }
[Inject] public required PersistentComponentState PersistentState { get; set; }
[Inject] public required NavigationManager Navigation { get; set; }
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
private IStreamingPlayerService? _subscribedService;
private PersistingComponentStateSubscription _persistingSubscription;
@@ -23,6 +30,11 @@ public partial class TracksView : ComponentBase, IDisposable
protected override async Task OnInitializedAsync()
{
// Seed filter state from the URL before any fetch or restore decision.
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
@@ -31,7 +43,11 @@ public partial class TracksView : ComponentBase, IDisposable
// restore on the interactive pass, and only fetch on a miss.
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
if (PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored) && restored is not null)
// The prerendered page is always unfiltered. When the URL carries filter params, that
// restored page is wrong for this view — skip the restore and fetch with the filter.
if (!ViewModel.HasActiveFilter
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
&& restored is not null)
{
ViewModel.Page = restored;
ViewModel.PageNumber = restored.Page;
@@ -63,7 +79,9 @@ public partial class TracksView : ComponentBase, IDisposable
private Task PersistTracks()
{
if (ViewModel.Page is not null)
// Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
// visit would show the wrong results, so a filtered render leaves the cache untouched.
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
{
PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
}
@@ -72,7 +90,9 @@ public partial class TracksView : ComponentBase, IDisposable
private async Task SetPage(int newPage)
{
var result = await ViewModel.TrackData.GetPage(newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending);
var result = await ViewModel.TrackData.GetPage(
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
{
@@ -81,6 +101,34 @@ public partial class TracksView : ComponentBase, IDisposable
}
}
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
// active filter (search + any album/genre pill compose).
private async Task OnSearchInput(string? value)
{
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
ViewModel.PageNumber = 1;
await SetPage(1);
StateHasChanged();
}
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
// active search term. Blazor reuses the component on a same-route query change and does not
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
// relying on re-init.
private async Task ClearFilter()
{
ViewModel.FilterAlbum = null;
ViewModel.FilterGenre = null;
ViewModel.PageNumber = 1;
Navigation.NavigateTo("/tracks");
await SetPage(1);
StateHasChanged();
}
private async Task PlayTrack(TrackDto track)
{
// Resume the current track if it's merely paused; otherwise stream the new selection.
@@ -24,3 +24,22 @@
justify-content: flex-end;
padding: 0 0 12px 0;
}
.tracks-search-row {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
::deep .tracks-search-field {
max-width: 420px;
width: 100%;
}
.tracks-filter-pills {
display: flex;
justify-content: flex-start;
padding: 0 0 12px 0;
}
@@ -16,7 +16,16 @@ public interface ITrackDataService
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false);
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null);
/// <summary>Distinct non-null albums with track counts and a representative cover key.</summary>
Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums();
/// <summary>Distinct non-null genres with track counts.</summary>
Task<ApiResult<List<GenreSummaryDto>>> GetGenres();
Task<ApiResult<TrackDto>> GetTrack(string trackId);
@@ -92,13 +92,17 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// track while it's still loading, not only after playback starts.
CurrentTrack = track;
// Create new cancellation token for this streaming operation
_streamingCancellation = new CancellationTokenSource();
// Create new cancellation token for this streaming operation. Capture it in a local
// so the catch/finally can compare identity against _streamingCancellation: a seek
// replaces _streamingCancellation with its own seekCts before this load's continuation
// resumes on the single-threaded WASM dispatcher, and we must not clobber the seek's state.
var loadCts = new CancellationTokenSource();
_streamingCancellation = loadCts;
// Fetch the waveform profile alongside the audio. Fire-and-forget against the same
// streaming token so a track switch abandons it; it only updates display state and must
// never gate or fail the audio load (a missing profile yields the flat-seekbar fallback).
_ = LoadWaveformProfileAsync(track.EntryKey, _streamingCancellation.Token);
_ = LoadWaveformProfileAsync(track.EntryKey, loadCts.Token);
try
{
@@ -119,7 +123,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
var mediaResult = await _trackMediaClient.GetTrackMedia(
track.EntryKey,
byteOffset: 0,
cancellationToken: _streamingCancellation.Token);
cancellationToken: loadCts.Token);
if (!mediaResult.Success)
{
var technicalError = mediaResult.GetMessage();
@@ -150,15 +154,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return;
}
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
await _activeStreamingTask;
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
{
// Cancellation is expected, reset state
// Cancellation is expected when this load was superseded (track switch or seek).
// The when filter ensures HttpClient timeout OCEs — where loadCts was NOT
// cancelled — fall through to the error handler below instead of being swallowed.
_logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);
IsLoaded = false;
IsStreamingMode = false;
// Only reset streaming state if this load is still the active operation. A seek
// in flight has already replaced _streamingCancellation with its own seekCts and
// owns IsLoaded/IsStreamingMode; clobbering them here corrupts the seek mid-flight.
if (ReferenceEquals(_streamingCancellation, loadCts))
{
IsLoaded = false;
IsStreamingMode = false;
}
}
catch (Exception ex)
{
@@ -171,7 +184,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
finally
{
IsLoading = false;
await NotifyStateChanged();
// Only notify if this load is still the active operation. A superseding seek
// owns state notifications; firing here mid-seek would push a stale snapshot.
if (ReferenceEquals(_streamingCancellation, loadCts))
{
await NotifyStateChanged();
}
}
}
@@ -327,6 +345,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
LoadProgress = 1.0;
await NotifyStateChanged();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Cancellation is expected during track switch or seek — propagate cleanly.
throw;
}
catch (Exception ex)
{
StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
@@ -377,7 +400,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
if (result.Success)
{
if (result.SeekBeyondBuffer && result.ByteOffset > 0)
if (result.SeekBeyondBuffer && result.ByteOffset >= 0)
{
// Need to load new stream from offset
_logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
@@ -424,10 +447,21 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
// OperationCanceledException asynchronously; if we kick off a new loop
// immediately, both can race against the single-instance JS StreamDecoder
// and corrupt decode state. Draining here is the load-bearing guarantee.
_streamingCancellation?.Cancel();
//
// Invariant: any caller that supersedes a load WITHOUT wanting the load's
// state reset must assign its own CTS to _streamingCancellation *before*
// its first await. LoadTrackStreaming's OCE continuation fires during the
// drain await on the shared _activeStreamingTask; it resets IsLoaded/
// IsStreamingMode only when _streamingCancellation still equals its loadCts.
// Assigning seekCts synchronously here makes that identity check fail, so
// the seek's state survives. (ResetToIdle deliberately does NOT do this —
// it wants the reset, and nulls _streamingCancellation only after the drain.)
var oldCts = _streamingCancellation;
var seekCts = new CancellationTokenSource();
_streamingCancellation = seekCts;
oldCts?.Cancel();
await DrainActiveStreamingTaskAsync();
_streamingCancellation?.Dispose();
_streamingCancellation = new CancellationTokenSource();
oldCts?.Dispose();
try
{
@@ -439,7 +473,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
var mediaResult = await _trackMediaClient.GetTrackMedia(
_currentTrackId,
byteOffset,
cancellationToken: _streamingCancellation.Token);
cancellationToken: seekCts.Token);
if (!mediaResult.Success || mediaResult.Value == null)
{
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
@@ -468,16 +502,21 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
BufferedChunks = 0;
// Stream audio from offset
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
_activeStreamingTask = StreamAudioWithEarlyPlayback(audio, seekCts.Token);
await _activeStreamingTask;
IsSeekingBeyondBuffer = false;
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (seekCts.IsCancellationRequested)
{
// Another seek or stop interrupted this one
// Another seek or stop interrupted this one. Only clear the flag if we are
// still the active seek — if _streamingCancellation has been replaced, a
// newer seek is in progress and owns the flag.
_logger.LogDebug("Seek beyond buffer cancelled");
IsSeekingBeyondBuffer = false;
if (ReferenceEquals(_streamingCancellation, seekCts))
{
IsSeekingBeyondBuffer = false;
}
}
catch (Exception ex)
{
@@ -23,8 +23,17 @@ public class TrackClientDataService : ITrackDataService
int pageNumber,
int pageSize,
string? sortColumn = null,
bool sortDescending = false)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending);
bool sortDescending = false,
string? searchText = null,
string? album = null,
string? genre = null)
=> _trackClient.GetPage(pageNumber, pageSize, sortColumn, sortDescending, searchText, album, genre);
public Task<ApiResult<List<AlbumSummaryDto>>> GetAlbums()
=> _trackClient.GetAlbums();
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres()
=> _trackClient.GetGenres();
public Task<ApiResult<TrackDto>> GetTrack(string trackId)
=> _trackClient.GetTrack(trackId);
@@ -26,6 +26,18 @@ public class TracksViewModel
public bool IsDescending { get; set; } = false;
public PagedResult<TrackDto>? Page { get; set; } = null;
// Active gallery filters. Null/empty means "no filter on this dimension". SearchText is the
// free-text query; FilterAlbum/FilterGenre are exact-match pills driven by the /albums and
// /genres pages via query-string navigation.
public string? SearchText { get; set; }
public string? FilterAlbum { get; set; }
public string? FilterGenre { get; set; }
public bool HasActiveFilter =>
!string.IsNullOrWhiteSpace(SearchText)
|| !string.IsNullOrWhiteSpace(FilterAlbum)
|| !string.IsNullOrWhiteSpace(FilterGenre);
public TracksViewModel(ITrackDataService trackData)
{
TrackData = trackData;
+7 -3
View File
@@ -87,11 +87,15 @@ The middleware pipeline in `Program.cs` is ordered as follows:
`TrackProxyController` in `Controllers/` is the only HTTP controller. It is a thin proxy only — no domain logic, no data layer. The WASM client points both named HttpClients (`"DeepDrft.API"` and `"DeepDrft.Content"`) at the Blazor host's base address, so all browser requests route through this controller to DeepDrftAPI. Server-side SSR calls DeepDrftAPI directly (server-to-server) via the same named clients — no proxy hop on the server side.
The proxy forwards two public, unauthenticated routes:
The proxy forwards public, unauthenticated routes:
- `GET api/track/page` — paged metadata listing
- `GET api/track/{trackId}` — WAV audio streaming (handles `offset` param for seek-beyond-buffer)
- `GET api/track/{trackId}` — WAV audio streaming (handles `Range` header for seek-beyond-buffer)
- `GET api/track/albums` — distinct albums with counts
- `GET api/track/genres` — distinct genres with counts
- `GET api/track/random` — random track selection
- `GET api/track/meta/by-key/{entryKey}` — metadata lookup by vault entry key
Both actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent.
All actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent.
## Development commands
@@ -22,18 +22,27 @@ public class TrackProxyController : ControllerBase
_logger = logger;
}
/// <summary>Proxies paged track metadata from DeepDrftAPI.</summary>
/// <summary>Proxies paged track metadata from DeepDrftAPI, forwarding optional filter params.</summary>
[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 ct = default)
{
var query = $"api/track/page?page={page}&pageSize={pageSize}&sortDescending={sortDescending}";
if (!string.IsNullOrWhiteSpace(sortColumn))
query += $"&sortColumn={Uri.EscapeDataString(sortColumn)}";
if (!string.IsNullOrWhiteSpace(q))
query += $"&q={Uri.EscapeDataString(q)}";
if (!string.IsNullOrWhiteSpace(album))
query += $"&album={Uri.EscapeDataString(album)}";
if (!string.IsNullOrWhiteSpace(genre))
query += $"&genre={Uri.EscapeDataString(genre)}";
HttpResponseMessage upstream;
try
@@ -92,6 +101,70 @@ public class TrackProxyController : ControllerBase
}
}
/// <summary>
/// Proxies the distinct-albums browse list from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed. Literal segment, declared before the
/// parameterized "{trackId}" route so it is never treated as a trackId.
/// </summary>
[HttpGet("albums")]
public async Task<ActionResult> GetAlbums(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/albums", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/albums failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/albums returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies the distinct-genres browse list from DeepDrftAPI. Unauthenticated, same posture as
/// the paged listing. Small JSON, buffered and relayed. Literal segment, declared before the
/// parameterized "{trackId}" route so it is never treated as a trackId.
/// </summary>
[HttpGet("genres")]
public async Task<ActionResult> GetGenres(CancellationToken ct = default)
{
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync("api/track/genres", HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Upstream call to DeepDrftAPI track/genres failed");
return StatusCode(502, "Upstream unavailable");
}
using (upstream)
{
if (!upstream.IsSuccessStatusCode)
{
_logger.LogWarning("DeepDrftAPI track/genres returned {Status}", (int)upstream.StatusCode);
return StatusCode((int)upstream.StatusCode);
}
var json = await upstream.Content.ReadAsStringAsync(ct);
return Content(json, "application/json");
}
}
/// <summary>
/// Proxies single-track metadata lookup by vault entry key from DeepDrftAPI. Unauthenticated,
/// same posture as the paged listing. Small JSON, so it is buffered and relayed; a 404 from
@@ -128,25 +201,33 @@ public class TrackProxyController : ControllerBase
}
/// <summary>
/// Proxies audio streaming from DeepDrftAPI. Passes the optional byte offset
/// so seek-beyond-buffer works through the proxy without buffering.
/// Proxies audio streaming from DeepDrftAPI as a transparent HTTP Range relay.
/// Forwards the incoming Range header upstream and relays the upstream status
/// (200 full, 206 partial, 416 unsatisfiable) and range-related response headers
/// back to the browser verbatim. The proxy does not slice — the upstream already did.
/// </summary>
[HttpGet("{trackId}")]
public async Task<ActionResult> GetTrack(
string trackId,
[FromQuery] long offset = 0,
CancellationToken ct = default)
{
_logger.LogInformation("Proxying track {TrackId} offset {Offset}", trackId, offset);
var rangeHeader = Request.Headers.Range.ToString();
_logger.LogInformation("Proxying track {TrackId} range '{Range}'", trackId, rangeHeader);
var path = offset == 0
? $"api/track/{Uri.EscapeDataString(trackId)}"
: $"api/track/{Uri.EscapeDataString(trackId)}?offset={offset}";
var request = new HttpRequestMessage(
HttpMethod.Get,
$"api/track/{Uri.EscapeDataString(trackId)}");
// Forward the browser's Range header upstream so DeepDrftAPI slices the file.
// TryAddWithoutValidation avoids RangeHeaderValue reparsing — we relay the raw
// header verbatim, keeping the proxy transparent.
if (!string.IsNullOrEmpty(rangeHeader))
request.Headers.TryAddWithoutValidation("Range", rangeHeader);
HttpResponseMessage upstream;
try
{
upstream = await _upstream.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, ct);
upstream = await _upstream.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}
catch (Exception ex)
{
@@ -154,6 +235,16 @@ public class TrackProxyController : ControllerBase
return StatusCode(502, "Upstream unavailable");
}
// 416 Range Not Satisfiable is a legitimate upstream answer (seek past EOF);
// relay it as-is rather than collapsing it into a 502.
if ((int)upstream.StatusCode == StatusCodes.Status416RangeNotSatisfiable)
{
upstream.Dispose();
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// 206 Partial Content reports IsSuccessStatusCode == true, so this guard only
// catches genuine upstream failures (404, 5xx).
if (!upstream.IsSuccessStatusCode)
{
upstream.Dispose();
@@ -161,18 +252,35 @@ public class TrackProxyController : ControllerBase
return StatusCode((int)upstream.StatusCode);
}
// Do NOT dispose upstream here — File() takes ownership of the response stream
// and disposes it after the body is sent.
var contentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
var contentLength = upstream.Content.Headers.ContentLength;
// Stream the body manually rather than via File(): FileStreamResult forces the
// status to 200 (with enableRangeProcessing:false) and would clobber a relayed
// 206. Writing status + headers + body directly keeps the partial-content
// contract intact, with the proxy doing zero slicing of its own.
HttpContext.Response.RegisterForDispose(upstream);
Response.StatusCode = (int)upstream.StatusCode;
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "audio/wav";
// Forward Content-Length so the WASM player has duration info from the WAV header length.
if (contentLength.HasValue)
Response.ContentLength = contentLength.Value;
// Relay range-related headers so the browser sees the same partial-content
// contract the upstream emitted.
if (upstream.Headers.AcceptRanges.Count > 0)
Response.Headers.AcceptRanges = string.Join(", ", upstream.Headers.AcceptRanges);
if (upstream.Content.Headers.ContentRange is { } contentRange)
Response.Headers.ContentRange = contentRange.ToString();
if (upstream.Content.Headers.ContentLength is { } contentLength)
Response.ContentLength = contentLength;
var stream = await upstream.Content.ReadAsStreamAsync(ct);
HttpContext.Response.RegisterForDispose(upstream);
return File(stream, contentType, enableRangeProcessing: false);
try
{
await stream.CopyToAsync(Response.Body, ct);
return new EmptyResult();
}
catch (OperationCanceledException)
{
// Client navigated away or issued a new seek — nothing to do.
// The upstream connection is cleaned up via RegisterForDispose.
return new EmptyResult();
}
}
/// <summary>
+18 -6
View File
@@ -321,18 +321,28 @@ export class AudioPlayer {
*/
private seekBeyondBuffer(position: number): AudioResult {
try {
const byteOffset = this.streamDecoder.calculateByteOffset(position);
const audioOffset = this.streamDecoder.calculateByteOffset(position);
// 0 is a valid offset (seek to start of audio data). Only a negative result
// indicates calculation failure — typically a missing/unparsed WAV header.
if (byteOffset < 0) {
if (audioOffset < 0) {
return { success: false, error: 'Cannot calculate byte offset' };
}
// Signal that C# needs to request new stream from offset
// The Range request is file-absolute: byte position from the start of the
// file on disk, header included. calculateByteOffset returns an audio-data-
// relative offset, so add headerSize to land on the right byte. (The old
// ?offset= contract was audio-relative; the server added the header itself.)
const header = this.streamDecoder.getWavHeader();
if (!header) {
return { success: false, error: 'Cannot calculate byte offset' };
}
const fileOffset = header.headerSize + audioOffset;
// Signal that C# needs to request a new stream from this file-absolute offset
return {
success: true,
seekBeyondBuffer: true,
byteOffset: byteOffset
byteOffset: fileOffset
};
} catch (error) {
return { success: false, error: (error as Error).message };
@@ -368,8 +378,10 @@ export class AudioPlayer {
this.scheduler.clearForSeek();
this.scheduler.setPlaybackOffset(seekPosition);
// Reinitialize decoder for new stream
this.streamDecoder.reinitializeForOffset(totalStreamLength);
// Reinitialize decoder for the Range-continuation stream. totalStreamLength
// here is the 206 Content-Length (range start → EOF), not the full file size —
// the decoder uses it to detect stream-complete against raw audio bytes.
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
// Update state
this.pausePosition = seekPosition;
+38 -7
View File
@@ -60,6 +60,14 @@ export class StreamDecoder {
private streamComplete: boolean = false;
private headerError: string | null = null;
// Range-continuation state. After a seek-beyond-buffer the server responds 206
// with raw PCM from a file-absolute offset (no WAV header). We retain the header
// parsed from the initial stream and treat the whole body as audio data. The
// stream-complete check then counts raw bytes against the 206 Content-Length
// (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
private isContinuation: boolean = false;
private remainingByteLength: number = 0;
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
@@ -84,6 +92,8 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
this.isContinuation = false;
this.remainingByteLength = 0;
}
/**
@@ -183,6 +193,16 @@ export class StreamDecoder {
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
// Range-continuation: the 206 body is pure audio (no header), so compare raw
// audio bytes directly against the 206 Content-Length. Do NOT add headerSize —
// there is no header in this response.
if (this.isContinuation) {
if (this.remainingByteLength > 0 && this.totalRawBytes >= this.remainingByteLength) {
this.streamComplete = true;
}
return;
}
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
@@ -445,23 +465,34 @@ export class StreamDecoder {
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
this.isContinuation = false;
this.remainingByteLength = 0;
}
/**
* Reinitialize for offset streaming - preserves header format knowledge
* Called when seeking beyond buffer to prepare for new stream from server
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
*
* The server responds to a Range request with 206 Partial Content carrying raw
* PCM from a file-absolute offset there is NO WAV header in this body. We retain
* the header parsed from the initial stream (its format describes every segment we
* synthesise via createWavFile) and feed the entire 206 body straight into the
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
* directly to addRawData and tryParseHeader is never re-entered.
*
* @param remainingByteLength the Content-Length of the 206 response the number of
* bytes from the range start to EOF, NOT the full file size. Stream-complete is
* reached when totalRawBytes >= this value.
*/
reinitializeForOffset(totalStreamLength: number): void {
// Reset data state but we'll get a fresh header from the offset stream
reinitializeForRangeContinuation(remainingByteLength: number): void {
// Retain this.wavHeader — the 206 body carries no header to reparse.
this.rawChunks = [];
this.totalRawBytes = 0;
this.processedBytes = 0;
this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
// wavHeader will be reparsed from the new stream (server sends fresh header)
this.wavHeader = null;
this.isContinuation = true;
this.remainingByteLength = remainingByteLength;
}
}
+18 -1
View File
@@ -59,7 +59,24 @@ class WavUtils {
// PCM only. The server's WavOffsetService synthesises PCM-shaped headers,
// and AudioProcessor rejects non-PCM at upload — accepting Float here would
// hand the decoder a header/payload mismatch that surfaces as garbled audio.
if (audioFormat !== 1) {
// WAVE_FORMAT_EXTENSIBLE (0xFFFE) is accepted only when its SubFormat GUID is
// PCM; the sample data is then byte-identical to standard PCM and every PCM
// field sits at the same offset. The vault normalizes uploads to plain PCM, so
// this is belt-and-suspenders for any EXTENSIBLE header that reaches the client.
if (audioFormat === 0xFFFE) {
// EXTENSIBLE needs the full extension: 16 base + 2 cbSize + 22 = 40 bytes.
if (chunkSize < 40) {
console.warn(`EXTENSIBLE fmt chunk too small: ${chunkSize} (need >= 40)`);
return null;
}
// SubFormat GUID at chunkOffset + 8 + 24; first two LE bytes are the format
// tag — 0x0001 == WAVE_FORMAT_PCM.
const subFormatTag = view.getUint16(chunkOffset + 8 + 24, true);
if (subFormatTag !== 1) {
console.warn(`Unsupported EXTENSIBLE SubFormat: ${subFormatTag} (only PCM supported)`);
return null;
}
} else if (audioFormat !== 1) {
console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 supported)`);
return null;
}
@@ -378,3 +378,51 @@ h2, h3, h4, h5, h6,
flex-direction: column;
}
}
/* =============================================================================
BUTTON UTILITIES (btn-primary, btn-ghost)
============================================================================= */
.btn-primary {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
background: var(--deepdrft-navy);
border: none;
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: background 0.25s, transform 0.2s;
display: inline-block;
}
.btn-primary:hover {
background: var(--deepdrft-green);
transform: translateY(-1px);
}
.btn-ghost {
font-family: var(--deepdrft-font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-navy);
background: transparent;
border: 1px solid var(--deepdrft-border);
padding: 1rem 2.2rem;
cursor: pointer;
text-decoration: none;
transition: border-color 0.25s, color 0.25s;
display: inline-block;
}
.btn-ghost:hover { border-color: var(--deepdrft-navy); }
@media (max-width: 599px) {
.btn-primary,
.btn-ghost {
text-align: center;
}
}
-1
View File
@@ -233,7 +233,6 @@ Be honest about coverage gaps:
- `TrackClient` / `TrackMediaClient` (HTTP clients).
- The audio player services (streaming, seek, interop).
- Dark-mode round-trip (cookie → settings → persistent state).
- `WavOffsetService` (byte offset → new WAV stream).
- `AudioProcessor` (WAV parsing, metadata extraction).
Any planned work in those areas should consider whether tests need to land alongside. **Testing the FileDatabase thoroughly does not mean testing everything** — it means testing the part that is most likely to break.
+2
View File
@@ -13,6 +13,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
@@ -28,6 +29,7 @@
<ItemGroup>
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
</ItemGroup>
</Project>
+205
View File
@@ -0,0 +1,205 @@
using Data.Data.Repositories;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Models.Common;
namespace DeepDrftTests;
/// <summary>
/// Query-shape tests for the Phase 2.2/2.3 filter and distinct-browse repository methods.
///
/// Provider note: these run on the EF in-memory provider, which executes LINQ in process. That
/// covers exact-match equality, null passthrough, GroupBy/Count, and ordering — every predicate
/// in <see cref="TrackRepository.GetPagedFilteredAsync"/> except the free-text branch. That branch
/// uses <c>EF.Functions.ILike</c>, an Npgsql-only relational function with no in-memory translation,
/// so the SearchText case is a Postgres integration test gated on a DSN (see SearchText_*). It is
/// ignored when no test database is configured rather than asserted against a provider that never
/// runs the predicate.
/// </summary>
[TestFixture]
public class TrackFilterQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
}
private TrackRepository CreateRepository()
=> new(_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
private static TrackEntity Track(
string name, string artist, string? album = null, string? genre = null, string? image = null)
=> new()
{
EntryKey = Guid.NewGuid().ToString("N"),
TrackName = name,
Artist = artist,
Album = album,
Genre = genre,
ImagePath = image,
};
private async Task SeedAsync(params TrackEntity[] tracks)
{
_context.Tracks.AddRange(tracks);
await _context.SaveChangesAsync();
}
private static PagingParameters<TrackEntity> DefaultPaging()
=> new() { Page = 1, PageSize = 20, OrderBy = t => t.Id, IsDescending = false };
// Case 2 — exact album match: returns only rows whose Album equals the filter value, and
// TotalCount reflects the filtered set, not the table.
[Test]
public async Task GetPagedFilteredAsync_WithExactAlbum_ReturnsOnlyThatAlbum()
{
await SeedAsync(
Track("One", "A", album: "Blue"),
Track("Two", "B", album: "Blue"),
Track("Three", "C", album: "Red"),
Track("Four", "D", album: null));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Album = "Blue" });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Two" }));
}
// Case 2b — exact genre match composes the same way as album.
[Test]
public async Task GetPagedFilteredAsync_WithExactGenre_ReturnsOnlyThatGenre()
{
await SeedAsync(
Track("One", "A", genre: "Techno"),
Track("Two", "B", genre: "House"),
Track("Three", "C", genre: "Techno"));
var repo = CreateRepository();
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { Genre = "Techno" });
Assert.That(result.TotalCount, Is.EqualTo(2));
Assert.That(result.Items.Select(t => t.TrackName), Is.EquivalentTo(new[] { "One", "Three" }));
}
// Case 3 — null filter is a passthrough: same items and count as the unfiltered base GetPagedAsync.
[Test]
public async Task GetPagedFilteredAsync_WithNullFilter_MatchesUnfilteredPagedQuery()
{
await SeedAsync(
Track("One", "A", album: "Blue"),
Track("Two", "B", album: "Red"),
Track("Three", "C"));
var repo = CreateRepository();
var baseline = await repo.GetPagedAsync(DefaultPaging());
var filtered = await repo.GetPagedFilteredAsync(DefaultPaging(), filter: null);
Assert.That(filtered.TotalCount, Is.EqualTo(baseline.TotalCount));
Assert.That(
filtered.Items.Select(t => t.Id),
Is.EqualTo(baseline.Items.Select(t => t.Id)).AsCollection);
}
// Case 4 — distinct albums: excludes null-album rows, counts per group, and takes the cover from
// the first track in the group that has a non-null ImagePath. Ordered by album ascending.
[Test]
public async Task GetDistinctAlbumsAsync_GroupsCountsAndPicksCover()
{
await SeedAsync(
Track("One", "A", album: "Zephyr", image: null),
Track("Two", "A", album: "Zephyr", image: "cover-z"),
Track("Three", "B", album: "Aria", image: "cover-a"),
Track("Four", "C", album: null, image: "ignored"));
var repo = CreateRepository();
var albums = await repo.GetDistinctAlbumsAsync();
Assert.That(albums.Select(a => a.Album), Is.EqualTo(new[] { "Aria", "Zephyr" }).AsCollection,
"albums sort ascending and the null-album track is excluded");
var zephyr = albums.Single(a => a.Album == "Zephyr");
Assert.That(zephyr.TrackCount, Is.EqualTo(2));
Assert.That(zephyr.CoverImageKey, Is.EqualTo("cover-z"),
"cover is the first non-null ImagePath in the group");
var aria = albums.Single(a => a.Album == "Aria");
Assert.That(aria.TrackCount, Is.EqualTo(1));
Assert.That(aria.CoverImageKey, Is.EqualTo("cover-a"));
}
// Case 5 — distinct genres: excludes null-genre rows, counts per group, ordered genre ascending.
[Test]
public async Task GetDistinctGenresAsync_GroupsCountsAndExcludesNull()
{
await SeedAsync(
Track("One", "A", genre: "Techno"),
Track("Two", "B", genre: "Ambient"),
Track("Three", "C", genre: "Techno"),
Track("Four", "D", genre: null));
var repo = CreateRepository();
var genres = await repo.GetDistinctGenresAsync();
Assert.That(genres.Select(g => g.Genre), Is.EqualTo(new[] { "Ambient", "Techno" }).AsCollection,
"genres sort ascending and the null-genre track is excluded");
Assert.That(genres.Single(g => g.Genre == "Techno").TrackCount, Is.EqualTo(2));
Assert.That(genres.Single(g => g.Genre == "Ambient").TrackCount, Is.EqualTo(1));
}
// Case 1 — free-text search across TrackName/Artist/Album, case-insensitive. EF.Functions.ILike
// is Npgsql-only and does not translate on the in-memory provider, so this runs only against a
// real Postgres database supplied via the DEEPDRFT_TEST_PG environment variable. Without it the
// test is ignored rather than asserted against a provider that cannot execute the predicate.
[Test]
public async Task GetPagedFilteredAsync_WithSearchText_MatchesNameArtistOrAlbumCaseInsensitive()
{
var dsn = Environment.GetEnvironmentVariable("DEEPDRFT_TEST_PG");
if (string.IsNullOrWhiteSpace(dsn))
Assert.Ignore("Set DEEPDRFT_TEST_PG to a Postgres connection string to run the ILike search test.");
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseNpgsql(dsn)
.Options;
await using var pg = new DeepDrftContext(options);
await pg.Database.EnsureCreatedAsync();
try
{
pg.Tracks.AddRange(
Track("Jazz Odyssey", "Spinal Tap", album: "Smell the Glove"),
Track("Quiet Storm", "jazzmin", album: "Nightfall"),
Track("Loud Noises", "Brick", album: "All JAZZ Hands"),
Track("Unrelated", "Nobody", album: "Silence"));
await pg.SaveChangesAsync();
var repo = new TrackRepository(
pg, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
var result = await repo.GetPagedFilteredAsync(DefaultPaging(), new TrackFilter { SearchText = "jazz" });
Assert.That(result.Items.Select(t => t.TrackName),
Is.EquivalentTo(new[] { "Jazz Odyssey", "Quiet Storm", "Loud Noises" }),
"ILike matches 'jazz' case-insensitively in TrackName, Artist, or Album");
}
finally
{
await pg.Database.EnsureDeletedAsync();
}
}
}
+12 -35
View File
@@ -23,6 +23,18 @@ What this means for the roadmap: the streaming substrate is solid. Future work c
These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.
### 1.1 Extended WAV format support
- **What:** Two EXTENSIBLE WAV sub-cases that were explicitly scoped out of the `WAVE_FORMAT_EXTENSIBLE` PCM fix (which shipped support for `audioFormat=0xFFFE` with a PCM SubFormat — the Bandcamp WAV download case). Both are currently rejected at `AudioProcessor.ValidateAudioParameters` and fall back to default metadata. The inline comments at `AudioProcessor.cs` (SubFormat check ~L182188, BlockAlign note ~L225230) mark them as accepted gaps as of that fix.
- **EXTENSIBLE non-PCM SubFormats** — e.g. IEEE Float (32-bit float PCM, common in DAW exports). The SubFormat-GUID check accepts only PCM (`0x0001`) today; anything else is rejected outright.
- **Padded-container EXTENSIBLE** — 24-bit valid samples in a 32-bit container (`wValidBitsPerSample=24`, container `bitsPerSample=32`). The BlockAlign check fails because the valid-bit depth (24) doesn't match the container's block align.
- **Why it matters:** DAW exports — the dominant shape of source material as the collective uploads more of its own production — tend to be float WAV or padded 24-bit. The shipped fix covers consumer/Bandcamp WAVs but not the producer's working files.
- **Shape:** Both live in the same seam as the shipped fix (`AudioProcessor` validation + the `NormalizeToStandardPcm` storage step), but the work differs by case:
- *Float SubFormat:* requires float→integer sample conversion during the normalize-to-standard-PCM step (the vault stays integer-PCM so the streaming/decode pipeline is unchanged), or a Web Audio decode path that handles float directly. The conversion-at-storage option keeps the load-bearing streaming seam untouched and is the lower-risk path.
- *Padded 24-in-32:* relax `ValidateAudioParameters` to tolerate the BlockAlign mismatch when `IsExtensible`, then normalize to the valid-bit depth (24) during storage so the stored WAV is canonical.
- **Prerequisite:** None. Both are self-contained extensions of the WAV path that just landed; neither depends on the broader format-router work in 1.2.
- **Relationship to 1.2:** Distinct from it. 1.2 is new *containers* (MP3, FLAC, Ogg) behind a format router; this is additional *WAV variants* on the existing PCM path. If 1.2's router lands first, these become per-variant branches inside the WAV processor rather than new processors.
### 1.2 Audio format diversity
- **What:** Today `AudioProcessor`, `WavOffsetService`, and the JS decoder are PCM/WAV-only. `MimeTypeExtensions` already maps MP3, FLAC, Ogg, AAC, M4A — none are wired.
@@ -79,25 +91,6 @@ These were flagged during the audit but classified as feature work, not defect f
These follow from `CONTEXT.md §5`. Direction is strongly implied but no specific UI has been committed.
### 2.2 Album and genre views
- **What:** `TrackCard` already renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions in `TrackService.GetPaged`.
- **Why it matters:** The track gallery is the only working content surface. Multiple views over the same library is how it earns the "gallery" name.
- **Shape:** Per `CONTEXT.md §6`, the convention is one source of truth, multiple views over it. New views should consume the same `TracksViewModel` / `PagedResult<TrackEntity>` and differ only at the rendering layer.
- `TrackService.GetPaged` extended to accept a filter expression (or a simple structured filter DTO).
- `PagingParameters<T>` extended with a `Where: Expression<Func<T, bool>>?` or a parallel `FilterParameters<T>` — pick one to avoid drift.
- New routes (`/albums`, `/genres`) consume the same VM with different grouping / filter inputs.
- **Prerequisite:** **2.1** for any view that prominently features cover art (album view especially is impoverished without it).
### 2.3 Search and filter on the gallery
- **What:** `TracksViewModel` exposes sort but no filter. `TrackService.GetPaged` accepts only sort. Simple text search across `TrackName` / `Artist` / `Album` is the obvious first cut.
- **Why it matters:** Once the library has more than ~30 entries, sort-only browsing is friction.
- **Shape:** Same extension to `GetPaged` as 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translates `Contains` to SQLite `LIKE`.
- **Prerequisite:** Fold into 2.2 if both are being done — the same `GetPaged` extension serves both. Doing them separately doubles the API churn.
---
## Phase 3 — New content kinds
@@ -117,22 +110,6 @@ These follow from `CONTEXT.md §5`. Direction is strongly implied but no specifi
## Phase 4 — Infrastructure / delivery
### 4.1 HTTP Range + CDN caching
- **What:** Today's `?offset=` query parameter defeats HTTP caching — a CDN sees `?offset=1234567` as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param.
- **Why it matters:** Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
- **Shape:** Two intertwined moves.
- Server: `LoadResourceStreamAsync` returning an open `FileStream` instead of `LoadResourceAsync` materialising the whole buffer. `File(stream, mime, enableRangeProcessing: true)`. The `WavOffsetService` synthesised-header path becomes a special-case rather than the default.
- Client: consider `MediaElementAudioSourceNode` instead of (or alongside) `decodeAudioData`-fed `AudioBufferSourceNode`s. Native seek, native range, native cache; FFT tap on the audio graph still works for the spectrum visualiser.
- **Prerequisite:** None functionally, but the audit explicitly flagged this trade-off as architecture-intentional — the current path was chosen because spectrum analysis wants `AudioBuffer`s. Re-deciding the trade-off is itself part of the work.
- **Constraint:** A move to `MediaElementAudioSourceNode` changes the early-playback story (the element handles buffering, not us). Worth a design pass.
### 4.2 Server-side stream from disk (no buffer materialisation)
- **What:** `LoadResourceAsync<AudioBinary>` reads the entire file into memory before `File(file.Buffer, mimeType)` returns it. A 100 MB WAV is a 100 MB LOH allocation per request.
- **Why it matters:** Scaling ceiling. Currently fine for a small audience and small library; not fine if either grows.
- **Shape:** Folds into 4.1 — the same `LoadResourceStreamAsync` overload solves both. Listed separately because either could land without the other (you could stream from disk while still using the `?offset=` query path, or you could move to `Range` headers while still buffering).
### 4.3 Dual-write rollback / dead-letter log
- **What:** If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.