Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4cda76114 | |||
| 38529a962a | |||
| d2a9475ba2 | |||
| e84823be39 | |||
| 6c602170a9 | |||
| 88ac5b2c88 | |||
| 0f5eaa42b5 | |||
| f0185587f7 | |||
| 0a5ddfdad8 | |||
| 8b94a5fdf7 | |||
| fb27918ed6 | |||
| 691d904273 | |||
| ded5a3e5eb | |||
| f25d0f624f | |||
| 43f54cb950 | |||
| f40940b957 | |||
| 10256677ac | |||
| 6fe7663667 | |||
| 5cae83b9ed | |||
| d9b92e0703 | |||
| 0fd1977353 | |||
| 1071ba7374 | |||
| 79a015f60a | |||
| 0bd7e6904d | |||
| f602eb9772 | |||
| b372bee365 | |||
| fad3635fa1 | |||
| 561f4a500a | |||
| 9be35e5a58 | |||
| aaa9f732ae | |||
| 5c3c3c3d0c |
@@ -14,7 +14,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Seven track endpoints: `GET api/track/{id}` unauthenticated streaming; `PUT api/track/{id}` vault write (ApiKey); `POST api/track/upload` upload + SQL persist (ApiKey); `DELETE api/track/{id:long}` SQL delete + vault remove (ApiKey); `GET api/track/page` paged metadata list (unauthenticated); `GET api/track/meta/{id:long}` single metadata (ApiKey); `PUT api/track/meta/{id:long}` metadata update (ApiKey).
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `WavOffsetService`, `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftContent**: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), `AudioProcessor`, content-side `TrackService`. Consumed by hosts and tests.
|
||||
- **DeepDrftModels**: Shared contracts. `TrackEntity`, `TrackDto`, `PagingParameters<T>`, `PagedResult<T>`. Every project references this.
|
||||
- **DeepDrftTests**: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.
|
||||
|
||||
@@ -70,7 +70,7 @@ The player is not fetch-then-play:
|
||||
2. `StreamingAudioPlayerService` reads in adaptive 16–64 KB chunks, pushes each via `AudioInteropService.processStreamingChunk`.
|
||||
3. TypeScript `StreamDecoder` parses WAV header, decodes chunks to `AudioBuffer`s. `PlaybackScheduler` schedules them on a Web Audio graph.
|
||||
4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}?offset={byteOffset}`. Server's `WavOffsetService` block-aligns offset, synthesises a fresh 44-byte WAV header, streams `[new header][data from offset]`. Player tears down and re-initialises decoder for the new stream.
|
||||
5. **Seek beyond buffer**: if seek target is past what's decoded, client issues `GET api/track/{id}` with `Range: bytes={byteOffset}-`. Server streams raw bytes from that file-absolute offset with a `206 Partial Content` response. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
|
||||
|
||||
Keep this seam clean — it is the most architecturally load-bearing part of the playback path.
|
||||
|
||||
|
||||
@@ -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
@@ -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 1–3
|
||||
- (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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DeepDrftContent.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating WAV audio streams starting from a byte offset.
|
||||
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||
/// </summary>
|
||||
public class WavOffsetService
|
||||
{
|
||||
/// <summary>
|
||||
/// WAV audio format code for linear PCM. The pipeline (AudioProcessor,
|
||||
/// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float
|
||||
/// (format 3) and other formats are rejected at parse time so the
|
||||
/// synthesized header here can safely assume PCM.
|
||||
/// </summary>
|
||||
public const short PcmFormat = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||
/// The returned stream is composed of a small header buffer and a non-owning slice over the input
|
||||
/// buffer — no copy of the audio payload is made.
|
||||
/// </summary>
|
||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||
{
|
||||
var format = ParseWavHeader(fullAudioBuffer);
|
||||
if (format == null)
|
||||
return null;
|
||||
|
||||
// Validate offset is within bounds and block-aligned
|
||||
if (byteOffset < 0 || byteOffset >= format.DataSize)
|
||||
return null;
|
||||
|
||||
// Align to block boundary for clean audio
|
||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||
|
||||
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||
var newDataSize = format.DataSize - alignedOffset;
|
||||
if (newDataSize <= 0)
|
||||
return null;
|
||||
|
||||
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
||||
if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue)
|
||||
throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported");
|
||||
|
||||
var newDataSizeInt = (int)newDataSize;
|
||||
var sourcePositionInt = (int)sourcePosition;
|
||||
|
||||
// Create new WAV header using the format reported by the parsed header.
|
||||
// PCM is the only format we accept (see PcmFormat / ParseWavHeader), but
|
||||
// threading format.AudioFormat through keeps the header self-consistent
|
||||
// and prevents drift if the validation contract is ever relaxed.
|
||||
var newHeader = CreateWavHeader(format, newDataSizeInt);
|
||||
|
||||
// Compose: 44-byte header followed by a non-copying slice of the audio payload.
|
||||
// Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy
|
||||
// that the previous MemoryStream(capacity).Write(...) implementation forced.
|
||||
var headerStream = new MemoryStream(newHeader, writable: false);
|
||||
var dataStream = new MemoryStream(
|
||||
fullAudioBuffer,
|
||||
sourcePositionInt,
|
||||
newDataSizeInt,
|
||||
writable: false,
|
||||
publiclyVisible: false);
|
||||
|
||||
return new ConcatStream(headerStream, dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the WAV header from a buffer to extract format information.
|
||||
/// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected
|
||||
/// so downstream synthesis can safely assume PCM sample encoding.
|
||||
/// </summary>
|
||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||
{
|
||||
if (buffer.Length < 44)
|
||||
return null;
|
||||
|
||||
// Check RIFF header
|
||||
var riff = Encoding.ASCII.GetString(buffer, 0, 4);
|
||||
if (riff != "RIFF")
|
||||
return null;
|
||||
|
||||
var wave = Encoding.ASCII.GetString(buffer, 8, 4);
|
||||
if (wave != "WAVE")
|
||||
return null;
|
||||
|
||||
// Variables to store parsed header info
|
||||
int sampleRate = 0;
|
||||
int channels = 0;
|
||||
int bitsPerSample = 0;
|
||||
int byteRate = 0;
|
||||
int blockAlign = 0;
|
||||
long dataSize = 0;
|
||||
int headerSize = 0;
|
||||
short audioFormat = 0;
|
||||
bool foundFmt = false;
|
||||
bool foundData = false;
|
||||
|
||||
// Find fmt and data chunks
|
||||
int chunkOffset = 12;
|
||||
while (chunkOffset < buffer.Length - 8)
|
||||
{
|
||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||
|
||||
if (chunkSize < 0)
|
||||
return null;
|
||||
|
||||
if (chunkId == "fmt " && !foundFmt)
|
||||
{
|
||||
// Use the first fmt chunk encountered — that is the WAV-spec-authoritative
|
||||
// chunk. Subsequent fmt chunks in a malformed file are ignored, matching
|
||||
// AudioProcessor.FindChunk which also returns the first match.
|
||||
if (chunkSize < 16)
|
||||
return null;
|
||||
|
||||
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||
// header below is PCM-shaped — accepting Float would produce a corrupt file
|
||||
// claiming PCM with Float-encoded samples. AudioProcessor also rejects
|
||||
// non-PCM at upload time so this branch is defense in depth.
|
||||
if (audioFormat != PcmFormat)
|
||||
return null;
|
||||
|
||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||
sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12);
|
||||
byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16);
|
||||
blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20);
|
||||
bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22);
|
||||
|
||||
// Basic validation
|
||||
if (channels < 1 || channels > 8)
|
||||
return null;
|
||||
|
||||
foundFmt = true;
|
||||
}
|
||||
else if (chunkId == "data")
|
||||
{
|
||||
// WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve
|
||||
// values above int.MaxValue (files between 2–4 GB), then widen to long.
|
||||
dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4);
|
||||
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||
foundData = true;
|
||||
}
|
||||
|
||||
// Move to next chunk with proper alignment (chunks are word-aligned)
|
||||
chunkOffset += 8 + ((chunkSize + 1) & ~1);
|
||||
|
||||
// If we found both chunks, we're done
|
||||
if (foundFmt && foundData)
|
||||
break;
|
||||
}
|
||||
|
||||
// Must have found both fmt and data chunks
|
||||
if (!foundFmt || !foundData)
|
||||
return null;
|
||||
|
||||
return new WavFormat(
|
||||
AudioFormat: audioFormat,
|
||||
SampleRate: sampleRate,
|
||||
Channels: channels,
|
||||
BitsPerSample: bitsPerSample,
|
||||
ByteRate: byteRate,
|
||||
BlockAlign: blockAlign,
|
||||
DataSize: dataSize,
|
||||
HeaderSize: headerSize
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||
/// </summary>
|
||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||
{
|
||||
var header = new byte[44];
|
||||
var fileSize = 36 + dataSize;
|
||||
|
||||
// RIFF header
|
||||
header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F';
|
||||
BitConverter.GetBytes(fileSize).CopyTo(header, 4);
|
||||
header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E';
|
||||
|
||||
// fmt chunk
|
||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
||||
BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size
|
||||
BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header)
|
||||
BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22);
|
||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||
BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32);
|
||||
BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34);
|
||||
|
||||
// data chunk header
|
||||
header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a';
|
||||
BitConverter.GetBytes(dataSize).CopyTo(header, 40);
|
||||
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WAV format information extracted from header.
|
||||
/// </summary>
|
||||
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||
public record WavFormat(
|
||||
short AudioFormat,
|
||||
int SampleRate,
|
||||
int Channels,
|
||||
int BitsPerSample,
|
||||
int ByteRate,
|
||||
int BlockAlign,
|
||||
long DataSize,
|
||||
int HeaderSize
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Forward-only read stream over two underlying streams concatenated end-to-end.
|
||||
/// Lets us serve "[synthesized header][slice of original buffer]" without
|
||||
/// allocating a single contiguous buffer for the combined payload.
|
||||
/// </summary>
|
||||
internal sealed class ConcatStream : Stream
|
||||
{
|
||||
private readonly Stream _first;
|
||||
private readonly Stream _second;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ConcatStream(Stream first, Stream second)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_length = first.Length + second.Length;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Loop over _first until it returns 0 (exhausted) or the caller's buffer
|
||||
// is full. Stream.Read is not required to fill the buffer in one call even
|
||||
// when data is available (e.g. a future non-MemoryStream _first), so we must
|
||||
// keep pulling until we get 0 before advancing to _second.
|
||||
while (count > 0 && _position < _first.Length)
|
||||
{
|
||||
var read = _first.Read(buffer, offset, count);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
offset += read;
|
||||
count -= read;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
var read = _second.Read(buffer, offset, count);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var total = 0;
|
||||
|
||||
// Same loop contract as Read() — exhaust _first before reading _second.
|
||||
while (!buffer.IsEmpty && _position < _first.Length)
|
||||
{
|
||||
var read = await _first.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
_position += read;
|
||||
buffer = buffer[read..];
|
||||
}
|
||||
|
||||
if (!buffer.IsEmpty)
|
||||
{
|
||||
var read = await _second.ReadAsync(buffer, cancellationToken);
|
||||
total += read;
|
||||
_position += read;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public override void Flush() { }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_first.Dispose();
|
||||
_second.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>One distinct genre with its track count. Backs the /genres browse list.</summary>
|
||||
public class GenreSummaryDto
|
||||
{
|
||||
public required string Genre { get; set; }
|
||||
public int TrackCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-project track filter contract. Threaded alongside (never inside) the external
|
||||
/// <c>PagingParameters<T></c>, which cannot carry a where-clause. An instance with all
|
||||
/// properties null is equivalent to no filter — see <c>TrackFilter.IsEmpty</c>.
|
||||
/// </summary>
|
||||
public class TrackFilter
|
||||
{
|
||||
/// <summary>Free-text, case-insensitive LIKE across TrackName, Artist, and Album.</summary>
|
||||
public string? SearchText { get; set; }
|
||||
|
||||
/// <summary>Exact album match.</summary>
|
||||
public string? Album { get; set; }
|
||||
|
||||
/// <summary>Exact genre match.</summary>
|
||||
public string? Genre { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when no predicate is set. An empty filter must produce identical results to a null
|
||||
/// filter, so callers collapse it to null before querying.
|
||||
/// </summary>
|
||||
public bool IsEmpty =>
|
||||
string.IsNullOrWhiteSpace(SearchText)
|
||||
&& string.IsNullOrWhiteSpace(Album)
|
||||
&& string.IsNullOrWhiteSpace(Genre);
|
||||
}
|
||||
@@ -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 (16–64 KB adaptive), pushes each via `AudioInteropService.ProcessStreamingChunkAsync` (JS interop call).
|
||||
3. TypeScript `StreamDecoder` parses WAV header (first chunk), decodes subsequent chunks to `AudioBuffer`s.
|
||||
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.
|
||||
|
||||
@@ -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 — 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;
|
||||
}
|
||||
@@ -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…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Stream Now ▶</span>
|
||||
}
|
||||
</button>
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶" />
|
||||
@* <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…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Stream Now ▶</span>
|
||||
}
|
||||
</button>
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶" 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ~L182–188, BlockAlign note ~L225–230) 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.
|
||||
|
||||
Reference in New Issue
Block a user