18 Commits

Author SHA1 Message Date
daniel-c-harvey 5298cab9b1 feature: Re-enable Dark Mode Toggle & App Bar Styles & Mobile App Bar Fixes
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m9s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m7s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m34s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m31s
2026-06-19 17:48:26 -04:00
daniel-c-harvey e05d93a67b docs: document upload staging directory and Upload:StagingPath config 2026-06-19 17:45:52 -04:00
daniel-c-harvey fd4fdd2624 docs: add Phase 18 theme/dark-mode remediation plan + product note 2026-06-19 17:41:11 -04:00
daniel-c-harvey 639f4741e6 Merge upload-temp-disk-fix into dev (stage large audio uploads on data disk instead of /tmp) 2026-06-19 17:37:26 -04:00
daniel-c-harvey d7071fdbc2 fix: always delete staging file on mid-copy abort
Build the staging path before the copy in both UploadTrack and ReplaceAudio so the finally block deletes it on cancellation or IO error, not only on success.
2026-06-19 17:36:06 -04:00
daniel-c-harvey 37cf19c405 fix: stage audio uploads on data disk instead of /tmp
Relocate both the framework multipart buffer (via ASPNETCORE_TEMP) and the controller staging file to a configurable data-disk directory, so large WAV/FLAC/MP3 uploads no longer fail on the host's small tmpfs.
2026-06-19 17:25:51 -04:00
daniel-c-harvey 37bbfb947f docs: note footer PRIVACY button + centered MudOverlay privacy modal 2026-06-19 17:09:37 -04:00
daniel-c-harvey 261b11436e Merge privacy-footer-overlay into dev (PRIVACY footer button + centered overlay note)
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m11s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m29s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 3m56s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m29s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m29s
2026-06-19 17:02:17 -04:00
daniel-c-harvey 280dbbcbc9 style: DRY footer btn CSS, add trailing newline, drop wrong section ordinal 2026-06-19 16:59:01 -04:00
daniel-c-harvey ce17a685e0 docs: reflect Phase 17 Wave 17.3 landing; Phase 17 complete 2026-06-19 16:48:48 -04:00
daniel-c-harvey 64379c8901 feat: move footer privacy note behind PRIVACY overlay button 2026-06-19 16:48:46 -04:00
daniel-c-harvey 1f8802363c Merge p17-w3-embed-panel into dev (Phase 17 Wave 17.3: Fixed embed queue panel + collapse/resize handshake) 2026-06-19 16:38:38 -04:00
daniel-c-harvey 58cdb4d9dc fix: isolate multi-embed resize handshake with per-snippet token
ForRelease mints a per-call token used as the iframe id and threaded into the src as EmbedId; the host script matches on it so multiple embeds resize independently. ForTrack unchanged.
2026-06-19 16:32:59 -04:00
daniel-c-harvey 97cce691db docs: document upload duplicate-detection rule, release/exists endpoint, and FindOrCreateRelease WasCreated contract 2026-06-19 16:25:50 -04:00
daniel-c-harvey d0be26bb3e Merge upload-duplicate-detection into dev (block duplicate-release uploads by title+artist) 2026-06-19 16:22:28 -04:00
daniel-c-harvey 466084b5a3 feat: Phase 17.3 — Fixed embed queue panel with collapse/expand iframe resize (OQ1 Option A)
Read-only inline queue panel below the release embed's player bar; row-jump reuses PlayRelease. ForRelease mints a taller iframe plus a postMessage resize listener for the collapse toggle; ForTrack unchanged.
2026-06-19 16:21:45 -04:00
daniel-c-harvey 558ff4b4c6 fix: close TOCTOU in CREATE path; add anti-forgery, loose-track, and case-sensitivity tests
FindOrCreateRelease now returns (ReleaseDto, bool WasCreated); the CREATE path in UploadAsync
rejects WasCreated=false as a duplicate rather than silently attaching on a lost race.
2026-06-19 15:55:08 -04:00
daniel-c-harvey bd85507308 Block duplicate-release uploads by (title, artist): pre-flight check + server 409 backstop, with within-batch Cut attach via releaseId 2026-06-19 15:44:41 -04:00
34 changed files with 1610 additions and 150 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
@@ -126,7 +126,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
## Folder-Level Guidance
+18
View File
@@ -6,6 +6,24 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
---
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
- **What:** The Fixed (embed) mode queue panel and the OQ1 Option-A iframe resize handshake. Release embeds now render an always-shown, read-only queue panel below the player-bar controls; the Queue button collapses/expands that panel and posts the iframe's new height to the host page so the outer `<iframe>` element resizes to match. Single-track embeds (TrackEntryKey mode) have no queue, no panel, and no Queue button — unchanged compact behaviour. Phase 17 is now complete (all four waves landed).
- **Why:** Phase 11 wave 11.F armed release embeds with a queue (skip navigation, auto-advance), but the viewer had no way to see or jump within the queue. Wave 17.3 surfaces it in Fixed mode — read-only because a shared embed is not an editable playlist — and resolves OQ1 (Option A confirmed feasible: `postMessage` resize degrades gracefully if the host strips the script).
- **Shape:**
- **Fixed embed queue panel** (`AudioPlayerBar.razor`): rendered conditionally on `ShowFixedPanel && _fixedPanelOpen` inside `.deepdrft-queue-embed-panel`; hosts `<QueueList Items="QueueItems" CurrentIndex="QueueCurrentIndex" Editable="false" OnJump="@OnQueueJump" />`. Read-only: no drag handles, no remove buttons. Row-jump (OQ2) calls `PlayRelease(Items, index)` — coherent from the armed-but-not-started state (`PlayRelease` already clears `IsArmed` and materializes a defensive copy).
- **Queue button in Fixed mode** (`PlayerTransportZone`): toggles `_fixedPanelOpen`; triggers a height post after the panel renders. Gated on `ShowFixedPanel` so single-track embeds see no button.
- **`EmbedSnippetBuilder.cs`** (`DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs`): `ForRelease` now mints a per-snippet random token (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`). Token is used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}`. Taller iframe height (release: 384 px vs. track: 196 px). Carries a host-side `<script>` listener that matches incoming `{type:"deepdrft-embed-resize", embedId}` messages against the snippet's own token and sets `iframe.style.height` — multiple release embeds on one host page resize independently (no cross-talk). Degrades to Option B if the host strips the script (panel still works inside the iframe at expanded height). `ForTrack` is unchanged (compact height 196 px, no script, no id token).
- **`embed-frame.ts`** (`DeepDrftPublic/Interop/embed/embed-frame.ts`; compiled output gitignored): new TypeScript interop module. Reads `EmbedId` from `window.location.search` once at module load; exports `postHeight(element: HTMLElement)` — measures the player element's rendered height (`Math.ceil(getBoundingClientRect().height) + 2`), builds `{type:"deepdrft-embed-resize", height, embedId?}` payload (omits `embedId` when absent for backward-compatible degradation), and calls `window.parent.postMessage(payload, "*")`. No-ops when not framed (`window.parent === window`) or the element is unmeasurable.
- **CSS** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): new `deepdrft-queue-embed-panel` and related `deepdrft-` embed-panel classes for the fixed queue panel chrome.
- **Tests** (`EmbedSnippetBuilderTests`): height divergence (ForRelease taller than ForTrack), ForTrack-unchanged (height 196, no script), id uniqueness (two ForRelease calls yield distinct ids), id/script-token consistency (iframe id matches token in script), EmbedId-in-src (token appears as `EmbedId=` in the iframe src).
---
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
**Landed:** 2026-06-19 on dev.
+21 -7
View File
@@ -16,7 +16,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
- `Controllers/TrackController.cs`: Track endpoints (see below).
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
@@ -120,6 +120,17 @@ Admin backfill: for every track whose `DurationSeconds` SQL column is still null
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
- Returns 200 on success. Returns 500 if the backfill operation fails.
### GET api/track/release/exists ([ApiKeyAuthorize])
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Query parameters**:
- `title` (string, required): the release title to check.
- `artist` (string, required): the artist name to check.
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. Returns 500 on query error.
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
@@ -156,10 +167,11 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
@@ -177,7 +189,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
- **Route parameter `id`** (long): the SQL track ID.
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
@@ -367,6 +379,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
**In `Program.cs`** (SQL + AuthBlocks + wiring):
@@ -389,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
## Configuration files
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
- `Logging`: standard ASP.NET structure.
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
+94 -50
View File
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -41,9 +43,48 @@ public class TrackController : ControllerBase
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
private string BuildStagingPath(string uploadExtension) =>
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
// must delete it in a finally block; separating path generation from the copy ensures the finally
// guard fires even when CopyToAsync throws before returning.
private async Task StageUploadAsync(
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
{
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
@@ -96,6 +137,37 @@ public class TrackController : ControllerBase
return Ok(result.Value);
}
// GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
[ApiKeyAuthorize]
[HttpGet("release/exists")]
public async Task<ActionResult> ReleaseExists(
[FromQuery] string? title,
[FromQuery] string? artist,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return BadRequest("title and artist are both required");
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
if (!result.Success)
{
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
return StatusCode(500, "Failed to check release");
}
if (result.Value is null)
return NotFound();
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.
@@ -220,6 +292,7 @@ public class TrackController : ControllerBase
[FromForm] string? releaseType,
[FromForm] string? medium,
[FromForm] int? trackNumber,
[FromForm] long? releaseId,
CancellationToken cancellationToken)
{
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
@@ -287,23 +360,15 @@ public class TrackController : ControllerBase
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
// generate our own path preserving the validated .wav/.mp3/.flac extension.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
@@ -315,6 +380,7 @@ public class TrackController : ControllerBase
parsedReleaseType,
parsedMedium,
resolvedTrackNumber,
releaseId,
cancellationToken);
if (!result.Success || result.Value is null)
@@ -322,14 +388,19 @@ public class TrackController : ControllerBase
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
// A cardinality rejection is a well-formed request that violates a domain rule, so it
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is
// stripped so the client sees only the human-readable detail.
// A cardinality or duplicate-release rejection is a well-formed request that violates a
// domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
// The marker is stripped so the client sees only the human-readable detail.
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
}
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
}
return StatusCode(500, error);
}
@@ -343,17 +414,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
@@ -529,21 +590,14 @@ public class TrackController : ControllerBase
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
// Build the staging path before the copy so the finally block can delete the partial file
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
var stagingPath = BuildStagingPath(uploadExtension);
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
@@ -566,17 +620,7 @@ public class TrackController : ControllerBase
}
finally
{
try
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
}
DeleteStagingFile(stagingPath);
}
}
+6
View File
@@ -18,6 +18,12 @@
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
</ItemGroup>
<ItemGroup>
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
<InternalsVisibleTo Include="DeepDrftTests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
+13
View File
@@ -0,0 +1,13 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
/// </summary>
public class UploadSettings
{
public string? StagingPath { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace DeepDrftAPI.Models
{
/// <summary>
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
/// </summary>
public sealed record UploadStagingDirectory(string Path);
}
+104 -28
View File
@@ -25,6 +25,16 @@ public class UnifiedTrackService
/// follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
/// <summary>
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
/// detail follows the marker and is what the CMS surfaces to the admin.
/// </summary>
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
private readonly TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase;
@@ -64,33 +74,66 @@ public class UnifiedTrackService
ReleaseType releaseType,
ReleaseMedium medium,
int trackNumber,
long? releaseId,
CancellationToken ct)
{
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
// find path can violate: a release that does not yet exist has zero tracks and admits its
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
// a future bounded medium is covered by the same line.
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
// orphans audio. Two paths:
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
// duplicate and is blocked (409).
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
// to the release row 1 just created. No (title, artist) lookup — the release id is
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
// future bounded medium is covered by the same line).
ResolvedRelease? resolved = null;
if (!string.IsNullOrWhiteSpace(album))
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
if (releaseId is { } attachId)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!attachPeek.Success)
{
var error = attachPeek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
if (peek.Value is { } existing)
{
var cardinality = MediumRules.CardinalityOf(existing.Medium);
if (existing.TrackCount + 1 > cardinality.Max)
// The attach target must be the same release the natural key resolves to — a guard against
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
if (attachPeek.Value is not { } target || target.Id != attachId)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
$"'{existing.Title}' already has one — edit the existing track or choose a different release.");
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
"Start the upload again.");
}
var cardinalityCheck = CheckCardinality(target);
if (cardinalityCheck is { } violation)
return ResultContainer<TrackDto>.CreateFailResult(violation);
resolved = new ResolvedRelease(target.Id);
}
else
{
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
if (!peek.Success)
{
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
}
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
// edits or appends to an existing release.
if (peek.Value is { } existing)
{
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
// resolved stays null → FindOrCreateRelease below creates the release.
}
}
@@ -109,9 +152,12 @@ public class UnifiedTrackService
// shared release (created on first sighting); an upload without one stays a loose track with
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track.
long? releaseId = null;
if (!string.IsNullOrWhiteSpace(album))
long? resolvedReleaseId = resolved?.Id;
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
{
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
// mints the release. (The attach path already resolved the id from the pre-check above and
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
var releaseData = new ReleaseDto
{
Title = album,
@@ -124,13 +170,13 @@ public class UnifiedTrackService
CreatedByUserId = createdByUserId,
};
// Medium (like every other field in releaseData) applies only when this upload CREATES the
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
// subsequent track add: medium is a release-level property, changed only via the edit path
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
// rather than silently attaching, keeping the DB unique index as the final safety net.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
if (!releaseResult.Success || releaseResult.Value is null)
if (!releaseResult.Success)
{
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError(
@@ -139,11 +185,21 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
}
releaseId = releaseResult.Value.Id;
var (resolvedRelease, wasCreated) = releaseResult.Value;
if (!wasCreated)
{
// The winning concurrent upload created this release between our peek and our insert.
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
return ResultContainer<TrackDto>.CreateFailResult(
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
}
resolvedReleaseId = resolvedRelease.Id;
}
var trackDto = TrackConverter.Convert(unpersisted);
trackDto.ReleaseId = releaseId;
trackDto.ReleaseId = resolvedReleaseId;
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
var saveResult = await _sqlTrackService.Create(trackDto);
@@ -166,6 +222,26 @@ public class UnifiedTrackService
return saveResult;
}
// The release a track resolved onto before the vault write. A null Id is the create path (mint
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
private readonly record struct ResolvedRelease(long Id);
// The cardinality guard shared by the attach path and (historically) the create path: a release
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
// message, or null when the add is within limits. The create path never trips this (a brand-new
// release has zero tracks and admits its first), so only the attach path calls it today.
private static string? CheckCardinality(ReleaseDto release)
{
var cardinality = MediumRules.CardinalityOf(release.Medium);
if (release.TrackCount + 1 > cardinality.Max)
{
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
}
return null;
}
/// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
+32
View File
@@ -47,9 +47,41 @@ namespace DeepDrftAPI
return db;
});
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
// on-disk copies of an upload off /tmp onto the data disk:
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
// Default location is a "staging" subdirectory beside the vaults; override with
// Upload:StagingPath in appsettings.json.
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
Directory.CreateDirectory(stagingPath);
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
// absent, so set it (and create the dir) before any request is served.
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
return Task.CompletedTask;
}
/// <summary>
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
/// </summary>
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
{
var path = string.IsNullOrWhiteSpace(configuredPath)
? Path.Combine(vaultPath, "staging")
: configuredPath;
return Path.GetFullPath(path);
}
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
{
if (!fileDatabase.HasVault(VaultConstants.Tracks))
+3
View File
@@ -7,6 +7,9 @@
}
},
"AllowedHosts": "*",
"Upload": {
"StagingPath": ""
},
"CorsSettings": {
"AllowedOrigins": [
"https://localhost:12778",
+1
View File
@@ -55,6 +55,7 @@ Notable repository / service methods beyond the standard CRUD:
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
+5 -1
View File
@@ -59,8 +59,12 @@ public interface ITrackService
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
/// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary>
+9 -9
View File
@@ -164,14 +164,14 @@ public class TrackManager
}
}
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{
try
{
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null)
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
// The natural key (title + artist) is authoritative — override whatever the caller put
// in releaseData so a typo upstream cannot create a release that won't be found again.
@@ -186,21 +186,21 @@ public class TrackManager
try
{
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
}
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{
// Concurrent upload inserted the same (title, artist) between our read and write.
// Re-query and return the winning row. Should not return null here since the
// constraint just fired, but re-throw if it does so the caller sees an error.
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
}
}
catch (Exception e)
{
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
}
}
@@ -302,13 +302,13 @@ public class TrackManager
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
if (!resolved.Success || resolved.Value is null)
if (!resolved.Success)
{
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error);
}
newTrack.ReleaseId = resolved.Value.Id;
newTrack.ReleaseId = resolved.Value.Release.Id;
}
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
@@ -146,6 +146,9 @@
private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut;
// The id of the release being edited. New tracks added in this session attach to it via the upload
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
private long? _releaseId;
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
@@ -214,6 +217,10 @@
}
var release = tracks[0].Release;
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
_releaseId = release?.Id ?? tracks[0].ReleaseId;
_albumName = albumName;
_artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty;
@@ -592,6 +599,7 @@
_releaseType,
trackNumber,
_medium,
_releaseId,
progress);
if (!uploadResult.Success || uploadResult.Value is null)
@@ -298,6 +298,29 @@
return;
}
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
// the admin is not surprised at the end of a long upload. The server backstops this on the
// create path, but checking here keeps the failure fast and visible. The values passed match
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
if (!duplicateCheck.Success)
{
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_errorMessage = $"Could not verify the release name: {checkError}";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
if (duplicateCheck.Value is { } existing)
{
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
Snackbar.Add(_errorMessage, Severity.Error);
return;
}
// For single-track media (Session/Mix) the track name is derived from the Release Name —
// no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
@@ -327,6 +350,11 @@
}
int succeeded = 0, failed = 0;
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
// reaches row 2 (single-track media collapse to one row).
long? batchReleaseId = null;
for (int i = 0; i < _tracks.Count; i++)
{
var row = _tracks[i];
@@ -375,6 +403,7 @@
_releaseType,
trackNumber,
_medium,
batchReleaseId,
progress);
if (!result.Success || result.Value is null)
@@ -387,6 +416,15 @@
}
else
{
// Capture the release id created by the first successful row so subsequent rows
// attach to it (the within-batch multi-track Cut path). Only set once — later
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
// which is correct: a release-less upload has no within-batch release to attach to.
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
{
batchReleaseId = createdReleaseId;
}
// The upload endpoint does not accept an imagePath, so link the cover art with
// a follow-up metadata update — same two-step pattern BatchEdit uses.
if (_imagePath is { } imgPath)
@@ -487,7 +525,13 @@
}
else
{
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
// Stay on page so the admin can see the failed rows.
}
}
@@ -68,6 +68,7 @@ public class CmsTrackService : ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default)
{
@@ -91,6 +92,9 @@ public class CmsTrackService : ICmsTrackService
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
// for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium");
// releaseId present → ATTACH (rows 2..N of a within-batch Cut); absent → CREATE (server rejects a
// pre-existing (title, artist) as a duplicate). Only sent when set so the form omits it on row 1.
if (releaseId is { } rid) multipart.Add(new StringContent(rid.ToString()), "releaseId");
var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
if (send.Response is not { } response)
@@ -474,6 +478,53 @@ public class CmsTrackService : ICmsTrackService
}
}
public async Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default)
{
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
var query = $"api/track/release/exists?title={Uri.EscapeDataString(title)}&artist={Uri.EscapeDataString(artist)}";
HttpResponseMessage response;
try
{
response = await client.GetAsync(query, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Content API call failed for release existence check ({Title}, {Artist})", title, artist);
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
}
using (response)
{
// 404 is the not-found (null) case, not a failure — no release matches this (title, artist).
if (response.StatusCode == HttpStatusCode.NotFound)
{
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Content API release existence check failed for ({Title}, {Artist}): {Status}",
title, artist, (int)response.StatusCode);
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to check for an existing release.");
}
ReleaseDto? release;
try
{
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize ReleaseDto from release existence check");
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
}
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
}
}
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
@@ -25,6 +25,10 @@ public interface ICmsTrackService
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
/// stalled connection aborts without a fixed total-duration cap.
/// <paramref name="releaseId"/> distinguishes the two rows of a within-batch multi-track Cut: null on
/// the first row (CREATE — the server rejects a pre-existing (title, artist) as a duplicate) and the
/// id returned by that first row on rows 2..N (ATTACH — the server skips the duplicate check and adds
/// the track to the release the batch just created).
/// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream,
@@ -42,9 +46,20 @@ public interface ICmsTrackService
ReleaseType releaseType,
int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null,
CancellationToken ct = default);
/// <summary>
/// Upload-form pre-flight: returns the existing release whose exact (title, artist) matches, or null
/// when none exists. Backs the duplicate block the form runs BEFORE transferring bytes, so the admin
/// is not surprised at the end of a long upload. A 404 from the API is the not-found (null) case, not
/// a failure. The match semantics are the API's <c>GetReleaseByTitleAndArtist</c> — the same read the
/// server backstop uses — so the pre-flight and the backstop agree.
/// </summary>
Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
string title, string artist, CancellationToken ct = default);
/// <summary>
/// Delete a track via the Content API, which removes the SQL row then the vault entry.
/// Maps a 404 to a "Track not found." failure.
+3 -3
View File
@@ -11,14 +11,14 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
## Actual structure
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`**`PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
- `Controls/`: Reusable components.
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
- `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
@@ -41,7 +41,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Helpers/`: Utilities and mapper functions.
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)``<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)``<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. Two targets diverge in height and content (Phase 17 wave 17.3): `ForTrack(baseUri, trackEntryKey)` compact `<iframe>` at 196 px (no queue panel, no script, unchanged from before 17.3). `ForRelease(baseUri, releaseEntryKey)` taller `<iframe>` at 384 px plus a host-side `<script>` resize listener; mints a fresh random token per call (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`) used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}` — the in-iframe `embed-frame.ts` reads this token and includes it in `postMessage` payloads so the host listener can route resize messages to the correct iframe when multiple release embeds share a host page. The script matches on `embedId` and applies `iframe.style.height`; degrades safely (panel still works inside the iframe) if the host strips the script. Pure string composition — unit-testable without rendering. TypeScript counterpart: `DeepDrftPublic/Interop/embed/embed-frame.ts` (compiled output gitignored).
- `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -26,7 +26,7 @@ else
SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious"
ShowQueueButton="ShowQueueButton"
QueueOpen="_queueOpen"
QueueOpen="QueueButtonOpen"
QueueToggle="@ToggleQueue"
Class="transport-zone"/>
@@ -42,6 +42,23 @@ else
Class="seek-zone"/>
</div>
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
shrunken height to the host iframe. *@
@if (ShowFixedPanel && _fixedPanelOpen)
{
<div class="deepdrft-queue-embed-panel">
<QueueList Items="QueueItems"
CurrentIndex="QueueCurrentIndex"
Editable="false"
OnJump="@OnQueueJump"/>
</div>
}
@* Minimize / close — positioned absolutely top-right *@
@if (!Fixed)
{
@@ -62,8 +79,8 @@ else
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
the Fixed embed gets its own inline panel in a later wave. *@
@if (ShowQueueButton)
the Fixed embed renders its own inline panel inside the surface above. *@
@if (ShowDockedOverlay)
{
<QueueOverlay Visible="_queueOpen"
Items="QueueItems"
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private IJSObjectReference? _spacerModule;
private bool _spacerObserved;
// Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we
// measure the player's live height and post it to the host so the iframe resizes to match. The
// dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first.
private IJSObjectReference? _embedModule;
private bool _embedHeightDirty;
private bool _embedHeightPosted;
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService?.IsLoading ?? false;
@@ -64,13 +71,31 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private bool HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false;
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
// Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
// skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
// single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
// the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A).
private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0;
private bool ShowQueueButton => HasQueue;
// The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead.
private bool ShowDockedOverlay => !Fixed && HasQueue;
// The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue.
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
private bool ShowFixedPanel => Fixed && HasQueue;
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
// up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space.
private bool _fixedPanelOpen = true;
// The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the
// inline panel's expanded state. One button, mode-appropriate meaning.
private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen;
/// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary>
@@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await QueueService.Previous();
}
private void ToggleQueue() => _queueOpen = !_queueOpen;
// Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so
// the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in
// OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host
// listener may simply not be present (Option B's behaviour).
private void ToggleQueue()
{
if (Fixed)
{
_fixedPanelOpen = !_fixedPanelOpen;
_embedHeightDirty = true;
return;
}
_queueOpen = !_queueOpen;
}
private void CloseQueue() => _queueOpen = false;
@@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// The Fixed embed is already in normal flow — no spacer/clip needed.
// Fixed embed: post the live player height to the host so the iframe sizes to the panel. We
// post on the first render (so the host snaps to the expanded panel rather than the snippet's
// initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/
// clip here — the embed is in normal flow.
if (Fixed)
{
if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty))
{
_embedHeightDirty = false;
_embedHeightPosted = true;
await PostEmbedHeight();
}
return;
}
// For the docked player: we observe in BOTH expanded and minimized states
// so --player-height always reflects the live height of whichever element
// is visible. This keeps the WaveformVisualizer clipped to the top of
@@ -169,7 +222,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
// minimized → observe _miniDock (floating FAB container, ~5660px)
// The player-spacer's .minimized class uses a hardcoded height and ignores
// the var, so publishing the FAB height here does not regress the spacer.
if (Fixed) return;
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
@@ -198,6 +250,37 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
}
// Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort:
// a missing module or a host that ignores the message just means no outer resize (Option B value).
private async Task PostEmbedHeight()
{
var module = await GetEmbedModuleAsync();
if (module is null) return;
try
{
await module.InvokeVoidAsync("postHeight", _playerRoot);
}
catch (JSException)
{
// Runtime gone or element detached mid-teardown — nothing actionable.
}
}
private async Task<IJSObjectReference?> GetEmbedModuleAsync()
{
try
{
return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/embed/embed-frame.js");
}
catch (JSException)
{
// Module failed to load — the panel still renders and toggles; only the outer resize is lost.
return null;
}
}
private async Task Expand() => await SetMinimized(false);
/// <summary>
@@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
}
_spacerModule = null;
}
if (_embedModule is not null)
{
try
{
await _embedModule.DisposeAsync();
}
catch (JSException)
{
// Runtime already gone (navigation/teardown) — nothing to clean up.
}
_embedModule = null;
}
}
}
@@ -3,19 +3,74 @@ namespace DeepDrftPublic.Client.Helpers;
/// <summary>
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
///
/// <para>
/// The two snippets diverge in height by design (Phase 17 §4.1, OQ6): a single-track embed has no
/// queue, so <see cref="ForTrack"/> stays at the compact player height; a release embed renders the
/// always-shown queue panel below the controls, so <see cref="ForRelease"/> is taller to show it
/// without clipping. Other iframe chrome (width, border radius, autoplay permission) is identical and
/// defined once in <see cref="Frame"/>.
/// </para>
///
/// <para>
/// OQ1 Option A — collapse/expand resize handshake. The release snippet carries a tiny host-side
/// listener: the embedded player posts its desired height when the viewer collapses/expands the
/// queue panel, and the listener sizes this specific iframe to match. It is scoped to the snippet's
/// own iframe (matched by id) and ignores any message whose shape does not match, so it cannot be
/// driven by foreign frames. It degrades to Option B's behaviour if the host strips the script: the
/// panel still renders and toggles inside the iframe at its default (expanded) height — only the
/// outer resize is lost. The track snippet needs no script (no panel, no toggle).
/// </para>
///
/// <para>
/// Multi-embed isolation: each <see cref="ForRelease"/> call mints a fresh random token (8 hex
/// chars). The token is used as the iframe id (<c>deepdrft-embed-{token}</c>) and threaded into
/// the iframe src as <c>&amp;EmbedId={token}</c> so the iframe can learn its own id. The host-side
/// resize script matches incoming messages on <c>embedId</c> and resizes only the iframe whose id
/// matches the token — two releases on one host page resize independently without cross-talk. Two
/// calls for the same release still get distinct tokens, ensuring uniqueness even when the same
/// release is pasted twice. Older snippets that lack <c>embedId</c> in their postMessage payload are
/// silently ignored by the script (backward-compatible degradation).
/// </para>
///
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
/// </summary>
public static class EmbedSnippetBuilder
{
// Compact single-track height (the historical embed height — must not change: UC6/AC6).
private const int TrackHeight = 196;
// Release height: the compact player plus the queue panel (fixed, internally scrollable past N
// rows per OQ6). The panel collapses to the track height via the resize handshake below.
private const int ReleaseHeight = 384;
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
public static string ForTrack(string baseUri, string trackEntryKey)
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}", TrackHeight);
public static string ForRelease(string baseUri, string releaseEntryKey)
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
{
// Mint a fresh random token per call so two embeds on the same host page never share an id,
// even when they point at the same release.
var token = Guid.NewGuid().ToString("N")[..8];
var iframeId = $"deepdrft-embed-{token}";
var src = $"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}&EmbedId={token}";
return Frame(src, ReleaseHeight, iframeId, ResizeScript(iframeId, token));
}
private static string Frame(string src)
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
=> $"""<iframe id="{iframeId}" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{trailingScript}""";
// Host-side listener: resize the matching iframe when the embedded player posts its panel height.
// The embedId field in the payload is matched against the snippet's own token so only this
// iframe is driven — foreign frames or other release embeds on the same page cannot interfere.
// The height is clamped to a sane floor so a bad payload can't collapse the player away.
// Messages without embedId (older snippets) are silently ignored.
private static string ResizeScript(string iframeId, string token) =>
"<script>(function(){var id=\"" + iframeId + "\",tok=\"" + token + "\";" +
"window.addEventListener(\"message\",function(e){var d=e.data;" +
"if(!d||d.type!==\"deepdrft-embed-resize\"||d.embedId!==tok)return;" +
"var f=document.getElementById(id);var h=Number(d.height);" +
"if(f&&h>=150)f.style.height=h+\"px\";});})();</script>";
}
@@ -4,8 +4,34 @@
<ul class="deepdrft-footer-links">
<li><a href="/about">About</a></li>
<li><a href="#">Contact</a></li>
<li><button class="deepdrft-footer-privacy-btn" @onclick="@OpenPrivacy" type="button" aria-haspopup="dialog">Privacy</button></li>
</ul>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
</div>
<p class="deepdrft-footer-privacy">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag&#8217;s gone.</p>
</footer>
</footer>
<MudOverlay Visible="@_privacyOpen"
DarkBackground="true"
Modal="true"
OnClick="@ClosePrivacy"
Class="deepdrft-privacy-overlay">
<div class="deepdrft-privacy-modal" @onclick:stopPropagation="true">
<div class="deepdrft-privacy-modal-header">
<span class="deepdrft-privacy-modal-title">Privacy</span>
<MudIconButton Icon="@Icons.Material.Filled.Close"
Size="Size.Small"
Color="Color.Default"
OnClick="@ClosePrivacy"
aria-label="Close privacy note"
Class="deepdrft-privacy-modal-close" />
</div>
<p class="deepdrft-privacy-modal-body">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag&#8217;s gone.</p>
</div>
</MudOverlay>
@code {
private bool _privacyOpen;
private void OpenPrivacy() => _privacyOpen = true;
private void ClosePrivacy() => _privacyOpen = false;
}
@@ -38,7 +38,8 @@
padding: 0;
}
.deepdrft-footer-links a {
.deepdrft-footer-links a,
.deepdrft-footer-links button {
font-family: var(--deepdrft-font-mono);
font-size: 0.62rem;
letter-spacing: 0.18em;
@@ -48,7 +49,8 @@
transition: color 0.2s;
}
.deepdrft-footer-links a:hover { color: var(--deepdrft-navy); }
.deepdrft-footer-links a:hover,
.deepdrft-footer-links button:hover { color: var(--deepdrft-navy); }
.deepdrft-footer-copy {
font-family: var(--deepdrft-font-mono);
@@ -57,14 +59,16 @@
color: var(--deepdrft-muted);
}
.deepdrft-footer-privacy {
font-family: var(--deepdrft-font-mono);
font-size: 0.55rem;
letter-spacing: 0.08em;
color: var(--deepdrft-muted);
opacity: 0.7;
/* PRIVACY trigger reset button chrome so it reads as a link, not a button element.
Typography/colour shared with footer <a> links via the grouped selector above. */
.deepdrft-footer-privacy-btn {
background: none;
border: none;
padding: 0;
margin: 0;
line-height: 1.6;
cursor: pointer;
line-height: inherit;
font-family: inherit;
}
@media (max-width: 440px) {
@@ -42,6 +42,7 @@
<div class="dd-nav-actions">
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now &#9654;"/>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
</nav>
</div>
@@ -49,7 +50,21 @@
@* Mobile Menu *@
<div class="d-flex d-sm-none">
<nav class="@NavClass">
<a class="dd-nav-brand" href="/">Deep DRFT</a>
<MudStack Row AlignItems="AlignItems.Center">
<a class="dd-nav-brand" href="/">
<MudImage Src="img/deepdrft-logo-l.webp"
Alt="Deep Drft Ornamental Logo Left"
Width="24"
Height="24 "/>
<span class="mx-2">Deep DRFT</span>
<MudImage Src="img/deepdrft-logo-r.webp"
Alt="Deep Drft Ornamental Logo Right"
Width="24"
Height="24 "/>
</a>
</MudStack>
<div class="dd-nav-actions">
<button type="button"
@@ -59,6 +74,8 @@
@onclick="ToggleMobileMenu">
<span></span><span></span><span></span>
</button>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
</div>
@if (_mobileMenuOpen)
@@ -117,6 +134,12 @@
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
private string DarkLightModeButtonIcon => IsDarkMode switch
{
true => DDIcons.GasLampLit,
false => DDIcons.GasLamp,
};
private async Task DarkModeToggle()
{
IsDarkMode = !IsDarkMode;
@@ -50,6 +50,10 @@
color: var(--deepdrft-white);
}
.dd-nav-dark .dd-nav-brand > ::deep img {
filter: invert(1);
}
/* Centred link list */
.dd-nav-links {
display: flex;
@@ -0,0 +1,55 @@
// Embed (iframe) → host resize handshake (Phase 17 wave 17.3, OQ1 Option A).
//
// The Fixed-mode player renders an always-shown queue panel below the controls. A collapse/expand
// toggle lets the embedder's viewer reclaim the panel's vertical space — but collapsing inside the
// iframe only reclaims space if the *outer* iframe element also shrinks. The iframe cannot resize
// itself, so it posts its desired pixel height to the host page; the embed snippet (minted by
// EmbedSnippetBuilder.ForRelease) carries a tiny listener that sets iframe.style.height.
//
// Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the
// panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing
// when not framed (window === parent), so the docked player is unaffected.
//
// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes
// it as ?EmbedId=<token> in the iframe src. We read it here from window.location.search and include
// it in the postMessage payload as `embedId`. The host-side resize script matches on this token so
// only the correct iframe is resized when multiple embeds share a host page. If EmbedId is absent
// (older already-pasted snippets), embedId is omitted from the payload — those snippets' scripts
// ignore messages without a matching embedId, so there is no cross-talk either way.
const MESSAGE_TYPE = "deepdrft-embed-resize";
/** Read the EmbedId query param from the iframe's own URL, if present. */
function readEmbedId(): string | null {
try {
return new URLSearchParams(window.location.search).get("EmbedId");
} catch {
return null;
}
}
// Resolved once at module load — the URL does not change while the iframe is alive.
const embedId: string | null = readEmbedId();
/**
* Measure the live rendered height of the player element and post it to the host page so it can size
* the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable.
*
* targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the
* payload carries no secrets just a height the host is free to ignore.
*
* The payload includes `embedId` when the iframe src carried an EmbedId query param. The host-side
* resize script matches on this field to isolate multiple embeds on the same page.
*/
export function postHeight(element: HTMLElement): void {
if (window.parent === window) return; // Not framed — nothing to resize.
if (!element) return;
// ceil + a hairline guard against sub-pixel rounding that would otherwise clip the bottom edge.
const height = Math.ceil(element.getBoundingClientRect().height) + 2;
if (!Number.isFinite(height) || height <= 0) return;
const payload: Record<string, unknown> = { type: MESSAGE_TYPE, height };
if (embedId !== null) payload.embedId = embedId;
window.parent.postMessage(payload, "*");
}
@@ -905,3 +905,94 @@ body:has(.deepdrft-queue-overlay) {
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
border-radius: 6px;
}
/* Fixed (embed) inline queue panel (Phase 17 §4, OQ6).
Rendered below the player controls inside the embed surface. A fixed sensible height with internal
scroll past N rows (NOT grow-to-cap): ~4.5 rows are visible, the rest scroll. A top hairline
separates it from the controls. The list rows reuse the shared .deepdrft-queue-* styles above. */
.deepdrft-queue-embed-panel {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--deepdrft-border-light);
max-height: 184px;
overflow-y: auto;
}
/* =============================================================================
PRIVACY OVERLAY
Screen-centered modal following the same MudOverlay idiom as the visualizer
controls and queue overlays. MudOverlay portals to body CSS isolation cannot
reach portaled content, so chrome lives here in the global sheet.
============================================================================= */
/* Raise above the sticky header (100), player dock (1200), and minimized FAB (1300). */
.deepdrft-privacy-overlay {
z-index: 1400 !important;
}
/* Mild tint: doubled selector (0,2,0) outranks MudBlazor's .mud-overlay-dark (0,1,0). */
.deepdrft-privacy-overlay .mud-overlay-scrim.mud-overlay-dark {
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
}
.deepdrft-privacy-overlay .mud-overlay-content {
max-height: 90vh;
overflow: visible;
}
/* Lock body scroll while the overlay is open. */
body:has(.deepdrft-privacy-overlay) {
overflow: hidden;
}
/* Panel: compact width, navy-panel ground, thin light border — matches queue/visualizer chrome. */
.deepdrft-privacy-modal {
display: flex;
flex-direction: column;
width: min(90vw, 480px);
background: var(--deepdrft-panel-ground);
border: 1px solid var(--deepdrft-border-light);
border-radius: 0;
backdrop-filter: blur(8px);
overflow: hidden;
}
.deepdrft-privacy-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 0.85rem 0.85rem 1rem;
border-bottom: 1px solid var(--deepdrft-border-light);
}
/* Mono uppercase eyebrow — matches queue modal title. */
.deepdrft-privacy-modal-title {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--deepdrft-white);
opacity: 0.85;
}
/* Tuck the close icon flush with the panel edge; keep it subtle. */
.deepdrft-privacy-modal-close {
opacity: 0.6;
color: var(--deepdrft-white) !important;
}
.deepdrft-privacy-modal-close:hover {
opacity: 1;
}
/* Privacy copy: same mono treatment as the former inline paragraph, but readable on dark ground. */
.deepdrft-privacy-modal-body {
font-family: var(--deepdrft-font-mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
line-height: 1.7;
color: var(--deepdrft-white);
opacity: 0.8;
margin: 0;
padding: 1rem 1rem 1.25rem;
}
+3
View File
@@ -28,6 +28,9 @@
</ItemGroup>
<ItemGroup>
<!-- Referenced for UnifiedTrackService — the dual-database upload orchestrator whose create-path
duplicate guard and within-batch attach path are exercised in UploadDuplicateDetectionTests. -->
<ProjectReference Include="..\DeepDrftAPI\DeepDrftAPI.csproj" />
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain
+112 -6
View File
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests;
@@ -5,8 +6,9 @@ namespace DeepDrftTests;
/// <summary>
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
/// across both. Pure string composition, tested directly without rendering the component.
/// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but
/// diverge in height by design (Phase 17 §4.1, OQ6): the release embed is taller to show its queue
/// panel; the track embed stays compact. Pure string composition, tested without rendering.
/// </summary>
[TestFixture]
public class EmbedSnippetBuilderTests
@@ -27,12 +29,13 @@ public class EmbedSnippetBuilderTests
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
// src contains ReleaseEntryKey; may also carry additional query params (e.g. EmbedId).
Assert.That(snippet, Does.Contain("ReleaseEntryKey=rel-xyz"));
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
}
[Test]
public void BothModes_ShareIdenticalIframeChrome()
public void BothModes_ShareIdenticalNonHeightChrome()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
@@ -43,12 +46,115 @@ public class EmbedSnippetBuilderTests
{
Assert.That(snippet, Does.StartWith("<iframe "));
Assert.That(snippet, Does.Contain(@"width=""656"""));
Assert.That(snippet, Does.Contain(@"height=""196"""));
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
Assert.That(snippet, Does.EndWith("></iframe>"));
Assert.That(snippet, Does.Contain("</iframe>"));
}
});
}
// T14 (Phase 17 §9): the release embed is taller than the track embed (it shows a queue panel),
// and the track embed's height is unchanged from its historical value (UC6/AC6).
[Test]
public void ForTrack_KeepsHistoricalCompactHeight()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
Assert.That(track, Does.Contain(@"height=""196"""));
}
[Test]
public void ForRelease_IsTallerThanForTrack_ToShowQueuePanel()
{
var trackHeight = HeightOf(EmbedSnippetBuilder.ForTrack(BaseUri, "k"));
var releaseHeight = HeightOf(EmbedSnippetBuilder.ForRelease(BaseUri, "k"));
Assert.That(releaseHeight, Is.GreaterThan(trackHeight));
}
// The release snippet carries the host-side resize listener (OQ1 Option A); the track snippet,
// having no panel to collapse, does not.
[Test]
public void ForRelease_IncludesResizeListenerScript()
{
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
Assert.Multiple(() =>
{
Assert.That(release, Does.Contain("<script>"));
Assert.That(release, Does.Contain("deepdrft-embed-resize"));
});
}
[Test]
public void ForTrack_HasNoResizeListenerScript()
{
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
Assert.That(track, Does.Not.Contain("<script>"));
}
// --- Multi-embed isolation (Phase 17 major remediation) ---
// Two ForRelease calls must produce snippets with different iframe ids so both can coexist on one
// host page without the host-side resize script resolving only the first via getElementById.
[Test]
public void ForRelease_TwoCalls_ProduceDifferentIframeIds()
{
var a = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
var b = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); // same release, different call
var idA = IframeId(a);
var idB = IframeId(b);
Assert.That(idA, Is.Not.EqualTo(idB),
"each ForRelease call must mint a distinct iframe id to prevent multi-embed cross-talk");
}
// The iframe id and the token embedded in the host-side resize script must be consistent within
// a single snippet — the script assigns the id string to a JS variable and calls getElementById
// with it, so the id literal must appear in the script's var initializer.
[Test]
public void ForRelease_IframeIdAndScriptToken_AreConsistentWithinOneSnippet()
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-abc");
var id = IframeId(snippet);
Assert.That(id, Does.StartWith("deepdrft-embed-"), "id must carry the expected prefix");
// The iframe element must declare the minted id.
Assert.That(snippet, Does.Contain($@"id=""{id}"""),
"iframe element must carry the minted id");
// The script stores the id in a JS var and calls getElementById(id) — confirm the id literal
// appears in the script's var initializer so the right iframe is targeted.
Assert.That(snippet, Does.Contain($@"var id=""{id}"""),
"resize script must initialise its id variable with the same minted id");
}
// The iframe src must carry EmbedId so the iframe content (embed-frame.ts) can read its own
// token and include it in postMessage payloads for the host-side script to match on.
[Test]
public void ForRelease_SrcCarriesEmbedIdParam()
{
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-def");
Assert.That(snippet, Does.Contain("EmbedId="),
"iframe src must include EmbedId query param so embed-frame.ts can read its own token");
}
private static int HeightOf(string snippet)
{
var match = Regex.Match(snippet, @"height=""(\d+)""");
Assert.That(match.Success, Is.True, "snippet must declare an iframe height");
return int.Parse(match.Groups[1].Value);
}
private static string IframeId(string snippet)
{
var match = Regex.Match(snippet, @"id=""([^""]+)""");
Assert.That(match.Success, Is.True, "snippet must declare an iframe id");
return match.Groups[1].Value;
}
}
+9 -9
View File
@@ -60,9 +60,9 @@ public class MediumWritePathTests
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
}
@@ -75,7 +75,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
}
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
@@ -87,7 +87,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
}
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
@@ -105,10 +105,10 @@ public class MediumWritePathTests
var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
}
@@ -207,9 +207,9 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True);
Assert.That(result.Value!.Description, Is.EqualTo(prose));
Assert.That(result.Value.Release.Description, Is.EqualTo(prose));
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
Assert.That(stored!.Description, Is.EqualTo(prose));
}
@@ -0,0 +1,292 @@
using System.Text;
using Data.Data.Repositories;
using Data.Managers;
using DeepDrftAPI.Services;
using DeepDrftContent;
using DeepDrftContent.Processors;
using DeepDrftData;
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.DTOs;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NetBlocks.Models;
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
namespace DeepDrftTests;
/// <summary>
/// Server-backstop coverage for upload duplicate detection. Drives the full
/// <see cref="UnifiedTrackService.UploadAsync"/> dual-database write over a real temp-isolated
/// <see cref="FileDb"/> vault and an EF in-memory <see cref="DeepDrftContext"/>, so the create-path
/// duplicate block, the within-batch attach path, and the existing single-track cardinality rule are
/// all asserted against the same orchestrator the controller calls.
///
/// The rule under test: a (title, artist) that pre-existed the submit is blocked on the CREATE path
/// (no releaseId), but the within-batch multi-track Cut still succeeds because rows 2..N pass the
/// release id row 1 created (ATTACH path) and so skip the duplicate lookup entirely.
/// </summary>
[TestFixture]
public class UploadDuplicateDetectionTests
{
private string _testDir = string.Empty;
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "UploadDuplicateDetectionTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown()
{
_context.Dispose();
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
private TrackManager CreateManager()
{
var repository = new TrackRepository(
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
return new TrackManager(
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
}
private async Task<UnifiedTrackService> CreateUnifiedServiceAsync(ITrackService sqlTrackService)
{
var fileDatabase = await FileDb.FromAsync(_testDir);
Assert.That(fileDatabase, Is.Not.Null);
var content = new TrackContentService(
fileDatabase!, new AudioProcessorRouter(
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
var waveforms = new WaveformProfileService(
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
return new UnifiedTrackService(
content, sqlTrackService, fileDatabase!, waveforms,
NullLogger<UnifiedTrackService>.Instance);
}
private async Task<string> WriteWavAsync(double durationSeconds)
{
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
await File.WriteAllBytesAsync(path, BuildMinimalPcmWav(durationSeconds));
return path;
}
private Task<ResultContainer<TrackDto>> UploadAsync(
UnifiedTrackService service, string tempPath, string trackName, string artist,
string? album, ReleaseMedium medium, long? releaseId)
=> service.UploadAsync(
tempPath, trackName, artist, album,
genre: null, description: null, releaseDate: null, createdByUserId: 1,
originalFileName: null, releaseType: ReleaseType.Single, medium: medium,
trackNumber: 1, releaseId: releaseId, ct: default);
// CREATE path: a brand-new single-track Mix succeeds (no pre-existing (title, artist)).
[Test]
public async Task UploadAsync_NewSingleTrackRelease_Succeeds()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Sunset Set", "DJ B", "Sunset Set", ReleaseMedium.Mix, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Not.Null);
}
// CREATE path: uploading a (title, artist) that already exists is blocked with the duplicate marker
// (which the controller maps to 409), for ANY medium — here a Cut.
[Test]
public async Task UploadAsync_DuplicateTitleArtist_IsBlockedWithDuplicateMarker()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True, "the first create must succeed");
// Second submit, same (title, artist), no releaseId → CREATE path → duplicate block.
var duplicate = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(duplicate.Success, Is.False);
var message = duplicate.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
Assert.That(message, Does.Contain("Studio Album"), "the block message names the existing release");
}
// The crux regression guard: a within-batch multi-track Cut. Row 1 CREATEs the release; row 2 passes
// row 1's ReleaseId (ATTACH path) and must succeed — the within-batch release is NOT a pre-existing
// duplicate. Both tracks end up on the same release.
[Test]
public async Task UploadAsync_WithinBatchMultiTrackCut_AttachesAndSucceeds()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var row1 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId: null);
Assert.That(row1.Success, Is.True, "row 1 creates the release");
var releaseId = row1.Value!.ReleaseId;
Assert.That(releaseId, Is.Not.Null);
// Row 2 attaches to the just-created release — same (title, artist), but with the explicit id.
var row2 = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId);
Assert.That(row2.Success, Is.True, row2.Messages.FirstOrDefault()?.Message);
Assert.That(row2.Value!.ReleaseId, Is.EqualTo(releaseId), "row 2 lands on the same release row 1 created");
var peek = (await ((ITrackService)manager).GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
Assert.That(peek.TrackCount, Is.EqualTo(2), "both within-batch tracks are on the one release");
}
// The existing single-track cardinality rule still fires on the attach path: a Session already
// holding its one track rejects a second add with the cardinality marker (controller → 409). This
// is reachable here only via an explicit releaseId, since a no-id second submit is the duplicate path.
[Test]
public async Task UploadAsync_SecondTrackOnSingleTrackRelease_IsBlockedWithCardinalityMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Live Set", "DJ A", "Live Set", ReleaseMedium.Session, releaseId: null);
Assert.That(first.Success, Is.True);
var releaseId = first.Value!.ReleaseId;
// A second track aimed at the same single-track Session via its id → cardinality rejection.
var second = await UploadAsync(
service, await WriteWavAsync(2.0), "Second Take", "DJ A", "Live Set", ReleaseMedium.Session, releaseId);
Assert.That(second.Success, Is.False);
var message = second.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.CardinalityViolationMarker));
}
// ATTACH anti-forgery guard: when the caller supplies a releaseId that does NOT match the release
// the natural key (title, artist) resolves to, the upload is rejected. Guards against a stale or
// forged releaseId pointing at a different (title, artist) than this row carries.
[Test]
public async Task UploadAsync_AttachWithMismatchedReleaseId_IsRejectedWithDuplicateMarker()
{
var manager = CreateManager();
var service = await CreateUnifiedServiceAsync(manager);
// Create two separate releases so we have two distinct ids.
var releaseA = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Release A", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseA.Success, Is.True, "release A must be created");
var idA = releaseA.Value!.ReleaseId!.Value;
var releaseB = await UploadAsync(
service, await WriteWavAsync(2.0), "Track One", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: null);
Assert.That(releaseB.Success, Is.True, "release B must be created");
// Try to ATTACH to release A while carrying release B's (title, artist). The natural-key lookup
// resolves to B — id A ≠ B.Id → anti-forgery guard fires.
var forged = await UploadAsync(
service, await WriteWavAsync(2.0), "Track Two", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: idA);
Assert.That(forged.Success, Is.False);
var message = forged.Messages.FirstOrDefault()?.Message ?? string.Empty;
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
}
// Loose-track success: an upload with null/whitespace album stays release-less (ReleaseId null).
// Confirms the duplicate guard is correctly bypassed for tracks that carry no album.
[Test]
public async Task UploadAsync_NullAlbum_SucceedsAsLooseTrack()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var result = await UploadAsync(
service, await WriteWavAsync(2.0), "Standalone Cut", "DJ Solo", album: null, ReleaseMedium.Cut, releaseId: null);
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
Assert.That(result.Value!.ReleaseId, Is.Null, "a null-album track must stay a loose track with no release");
}
// Case-sensitivity caveat: the assertion below verifies ordinal == equality as implemented by the
// EF in-memory provider (which evaluates LINQ predicates in-process). The deployed PostgreSQL
// instance may use a different column collation (e.g. case-insensitive) — production case-sensitivity
// depends on the collation of the `release` table's `title` and `artist` columns, not on this test.
// Matching semantics: GetReleaseByTitleAndArtist (the read both the pre-flight and the create-path
// duplicate guard use) is exact — a case difference is NOT a match, so it does not trip the block.
// This asserts the pre-flight and the create path agree by using the one shared read.
[Test]
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicateOnInMemoryProvider()
{
var service = await CreateUnifiedServiceAsync(CreateManager());
var first = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
Assert.That(first.Success, Is.True);
// Different case → not the same natural key under the in-memory provider's ordinal == →
// admitted as a new release. Production outcome depends on PostgreSQL column collation.
var differentCase = await UploadAsync(
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "STUDIO ALBUM", ReleaseMedium.Cut, releaseId: null);
Assert.That(differentCase.Success, Is.True, differentCase.Messages.FirstOrDefault()?.Message);
}
// Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale square
// wave (non-silent so the loudness algorithm yields a real envelope). Same layout as
// TrackReplaceAudioTests / WaveformProfileServiceTests.
private static byte[] BuildMinimalPcmWav(double durationSeconds)
{
const int sampleRate = 44100;
const ushort channels = 1;
const ushort bitsPerSample = 16;
const ushort blockAlign = channels * (bitsPerSample / 8);
const uint byteRate = sampleRate * blockAlign;
var frames = (int)(sampleRate * durationSeconds);
var data = new byte[frames * blockAlign];
for (var i = 0; i < frames; i++)
{
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
data[i * 2] = (byte)(sample & 0xFF);
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
}
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + data.Length));
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(16u);
w.Write((ushort)1); // PCM
w.Write(channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write(bitsPerSample);
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
}
+65
View File
@@ -0,0 +1,65 @@
using DeepDrftAPI;
namespace DeepDrftTests;
/// <summary>
/// Guards the upload-staging directory resolution (<see cref="Startup.ResolveStagingPath"/>). The
/// load-bearing invariant: large audio bodies must stage on the data disk, never the system temp
/// mount — on the Linux host /tmp is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB WAV.
/// </summary>
[TestFixture]
public class UploadStagingPathTests
{
[Test]
public void ResolveStagingPath_DefaultsToStagingUnderVault_WhenUnconfigured()
{
var vaultPath = Path.Combine(Path.GetTempPath(), "DeepDrftTests", Guid.NewGuid().ToString());
foreach (var configured in new[] { null, "", " " })
{
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.Multiple(() =>
{
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(Path.Combine(vaultPath, "staging"))),
"An unset/blank StagingPath must default to a 'staging' subdirectory under the vault path");
Assert.That(Path.IsPathFullyQualified(resolved), Is.True,
"The resolved staging path must be absolute");
});
}
}
[Test]
public void ResolveStagingPath_HonoursExplicitOverride()
{
var vaultPath = Path.Combine("data", "vaults");
var configured = Path.Combine(Path.GetTempPath(), "DeepDrftTests", "custom-staging", Guid.NewGuid().ToString());
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(configured)),
"An explicit Upload:StagingPath must win over the vault-path default");
}
[Test]
public void ResolveStagingPath_NeverResolvesIntoSystemTempDirectory_ForDataDiskVault()
{
// A production-shaped vault path on the data disk (the real config is a relative "../Database/Vaults").
// The resolved staging dir must sit under that vault, not under Path.GetTempPath() (= /tmp on Linux).
var vaultPath = Path.Combine("..", "Database", "Vaults");
var resolved = Startup.ResolveStagingPath(configuredPath: null, vaultPath);
var systemTemp = Path.GetFullPath(Path.GetTempPath());
// Note: because vaultPath is relative, Path.GetFullPath resolves it against the CWD, which is
// never the system temp directory. The StartsWith guard therefore catches the case where
// ResolveStagingPath mistakenly uses Path.GetTempPath() directly, rather than proving the
// absolute production path never overlaps with /tmp on any machine. The EndsWith assertion
// is the load-bearing check: it verifies the output is rooted under the vault tree, not
// under a hard-coded temp location.
Assert.That(resolved.StartsWith(systemTemp, StringComparison.Ordinal), Is.False,
"The default staging directory must never live under the system temp mount");
Assert.That(resolved, Does.EndWith(Path.Combine("Database", "Vaults", "staging")),
"The default staging directory must hang off the vault path on the data disk");
}
}
+47 -6
View File
@@ -304,18 +304,21 @@ it can begin immediately. **Landed:** 2026-06-19 on dev. 17.2 (docked overlay, e
`MudDropContainer` reorder) and 17.3 (Fixed embed panel + snippet resize — **the OQ1
Option-A-vs-B feasibility call is made here**) hang off it and are largely parallel. Add-to-Queue
split to a standalone 17.4 (needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. 17.3 remains
pending.
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. **Landed
(17.3):** 2026-06-19 on dev.
**Phase 17 is complete.** All four waves (17.1 engine additions + shared `QueueList`, 17.2 docked
overlay, 17.3 Fixed embed panel + iframe resize handshake, 17.4 Add-to-Queue affordance) landed on
dev 2026-06-19. See `COMPLETED.md §17` for the full completion records.
Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
the open-question set: `product-notes/phase-17-player-queue-view.md`.
**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**
- **OQ1****Option A, conditional** — collapse/expand toggle *if* the embed snippet can dynamically
resize the iframe (`postMessage` → host resize handshake), **else fall back to Option B** (omit the
button); A preferred, B fallback, deciding factor = iframe-resize feasibility, **determined during
17.3**.
- **OQ1****Option A, confirmed (17.3)** — collapse/expand toggle with `postMessage` → host resize
handshake implemented. `EmbedSnippetBuilder.ForRelease` carries the host-side listener; `embed-frame.ts`
posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
- **OQ2****yes, both modes** — clicking a queued row jumps playback to that track in the docked
overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove
@@ -339,6 +342,44 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
---
## Phase 18 — Theme / Dark-Mode Remediation (DRY token pass)
A punch-list of six theming symptoms Daniel reported — five in dark mode, one in light —
that all trace to **three** root causes in how component/page CSS bypasses the theme-aware
token layer and binds *constant* source tokens instead. Resolved as one coherent token pass,
not six per-component patches. Full design, architecture map, root-cause analysis, token
table, and track breakdown: `product-notes/theme-dark-mode-remediation.md`.
**Root-cause collapse (six symptoms → three causes):**
- **Cause 1 — neutral surfaces don't invert.** Home hero-left + footer (#3) and About light
sections (#4) hardcode `background: var(--deepdrft-white)` / text on `--deepdrft-navy`
brand *constants* that are identical in `:root` and `.deepdrft-theme-dark`, so they cannot
flip. Fix: bind a theme-aware `--deepdrft-page-surface` / `--deepdrft-page-text` alias. The
inversion must stay **neutral to the intentionally navy/green decorative sections**
(`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) — a classify-then-recolor job.
- **Cause 2 — play chip binds a constant grey.** `PlayStateIcon.razor.css` `.icon-container`
hardcodes `--deepdrft-soft` (#e3e7ec). One shared component drives the release-hero chip, the
Cut track rows, *and* the player bar — so it reads "greyed-out" over dark heroes (#5) and "too
bright" on the navy player surface (#6). Fix: theme-aware `--deepdrft-play-chip` (moss-green +
navy glyph in dark) with a translucent `--deepdrft-play-chip-soft` override for the player bar.
- **Cause 3 — no theme-aware popover surface.** Light-mode default MudPopovers read "too dark"
(#1); there's no token for the wanted "desaturated navy." Fix: a `--deepdrft-popover-surface`
token; leave the bespoke `--deepdrft-panel-ground` panels alone.
**Sequenced as four tracks, `T1 → {T2, T3, T4}`.** T1 (additive token foundation in
`deepdrft-tokens.css`) is the cold-start prerequisite; T2 (neutral-surface inversion), T3
(play-chip theming), T4 (popover token) fan out behind it and are mutually independent. Pure
CSS-token pass — no source code, data layer, or streaming-seam changes. Prior art:
`product-notes/track-card-theming.md` solved this exact class of theme-aware recolor once
already; this generalizes the fix from one component to the pattern.
**Open questions for Daniel (spec §5):** (1) dark neutral surface = navy *ground* (continuous
field — recommended for footer/hero) vs. *elevated* navy-mid (distinct panels); (2) popover
target distance from white (recommend a light `color-mix(navy ~8%, white)` wash). Exact green
opacity + muted-text mixes are tune-on-screen details, not decision gates.
---
## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
@@ -0,0 +1,243 @@
# Theme / Dark-Mode Remediation — DRY token pass
Status: proposed. Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).
A design analysis of the DeepDrft theme system, focused on the dark theme, with a DRY
remediation plan that resolves a punch-list of six reported symptoms through **shared
theme tokens** rather than per-component patches. Daniel reported the symptoms; this note
maps the architecture, isolates the root causes, and sequences the fix.
Prior art this borrows from: `product-notes/track-card-theming.md` (landed 2026-06-05) —
the same class of problem (theme-aware recolor under `.deepdrft-theme-dark`, legible in
both palettes) solved once already with the same mechanism. This note generalizes that
fix from one component to the recurring pattern behind it.
---
## 1. How the theme system is wired today (the map)
There are **three** colour layers, and the bugs all live in how the third one bypasses the
first two.
### Layer A — MudBlazor palettes (C#)
`DeepDrftShared.Client/Common/DeepDrftPalettes.cs` defines `PaletteLight Light`,
`PaletteDark Dark` (+ `CmsLight`, `EmbedLight`, `EmbedDark`). `MainLayout.razor` mounts
`<MudThemeProvider Theme="DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />`. MudBlazor
injects these as `--mud-palette-*` CSS variables that **flip automatically** when
`IsDarkMode` toggles. This is the part that works: anything reading `--mud-palette-surface`,
`--mud-palette-background`, `--mud-palette-text-primary` inverts correctly for free.
### Layer B — DeepDrft design tokens (CSS)
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two token families:
- **Source tokens** — raw brand colours, *constant across both themes*:
`--deepdrft-navy (#112338)`, `--deepdrft-white (#FAFAF8)`, `--deepdrft-green-accent
(#3D7A68)`, `--deepdrft-soft (#e3e7ec)`, etc. These never change between light and dark.
- **Theme-aware aliases**`--theme-surface`, `--theme-surface-soft`, `--theme-primary…senary`,
`--gradient-base/accent/warm/light`, `--deepdrft-surface`, `--deepdrft-background`. These
**are** redefined inside the `.deepdrft-theme-dark` block (the wrapper class
`MainLayout.ThemeWrapperClass` puts on the root div), so they flip.
The token file's own header comment establishes the intended discipline: source tokens are
"source of truth"; theme-aware aliases are what page CSS is *supposed* to consume so it
"resolve[s] coherently across themes."
### Layer C — component / page CSS
Scoped `*.razor.css` files and the global `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
**This is where the discipline breaks.** Page sections that should track the theme surface
instead reach straight past Layer B and bind to the *constant source tokens* of Layer A
(`--deepdrft-white`, `--deepdrft-navy`, `--deepdrft-soft`). A constant cannot invert — so
these surfaces stay light-on-navy-site no matter the mode.
---
## 2. Root causes (six symptoms → three causes)
The six reported symptoms collapse to **three** root causes. That collapse is the whole
point of doing this as one coherent pass rather than six patches.
### Cause 1 — "neutral surface" sections bind to constant source tokens, so they never invert
*(Symptoms: Home hero-left + footer (#3); About light sections (#4))*
These rules are the smoking gun (all bind a constant, not a theme alias):
- `Home.razor.css``.hero-left`, `.section`, `.section-divider`, `.section-body p`,
`.medium-card`, `.split-right`, `.connect-*``background: var(--deepdrft-white)`,
text `color: var(--deepdrft-navy)`.
- `About.razor.css``.hero-left`, `.hero-image-pane`, `.bio`/process gradients →
`background: var(--deepdrft-white)`, text on `--deepdrft-navy`.
- `DeepDrftFooter.razor.css``.deepdrft-footer``background: var(--deepdrft-white)`,
logo/links text on `--deepdrft-navy` / `--deepdrft-muted`.
`--deepdrft-white` is `#FAFAF8` in **both** `:root` and `.deepdrft-theme-dark` — it is a
brand constant, never re-aliased. So in dark mode these read as bright off-white panels with
dark text floating in a navy site. The fix is **not** to hardcode a dark colour; it is to
**bind these surfaces to a theme-aware alias** that already inverts.
**Critical nuance Daniel flagged:** the fix must be *neutral to the existing navy and green
accent sections.* The page already has sections that are **intentionally** navy/green in
both modes — `.section-dark` (navy), `.split-left` (green), `.cta-banner` (navy), the
`ReleaseHeroOverlay` (dark image). Those are decorative-by-design and must **not** be touched
by the inversion. Only the "default page surface" sections (the ones currently white-because-
light) should flip. This is a *classification* problem first, a recolor second: separate
"neutral surface" from "decorative accent" and only re-token the former.
### Cause 2 — the play-icon chip background binds `--deepdrft-soft` (constant light grey)
*(Symptoms: greyed-out play icon on release heroes / track lists (#5); too-bright player-bar play button (#6))*
`PlayStateIcon.razor.css` `.icon-container` hardcodes `background-color: var(--deepdrft-soft)`
(`#e3e7ec` — a light grey, constant across both themes). `PlayStateIcon` is the **single**
glyph component used by the release heroes, the Cut track rows, *and* the player bar. So one
constant drives all of these:
- Over a **dark hero image / navy track list** → the light-grey chip reads dull and
"greyed-out" (#5). Daniel wants: **moss-green chip background, navy play glyph** in dark mode.
- On the **bright player-surface** → the same light-grey chip reads "very bright" against the
navy dock (#6). Daniel wants: **same green, much less opaque** (a translucent green wash,
not a solid bright fill).
Both are the same `--deepdrft-soft` constant failing to be theme-aware. One component, one
token — fix the token's dark-mode value and both surfaces resolve. Note the two contexts want
*different green treatments* (solid green chip on the hero; translucent green wash in the
player bar), so the chip background should be a **token the player-bar context can override**,
not a single flat value — see §3.
### Cause 3 — popover surface has no theme-aware token; light mode reads "too dark"
*(Symptom: light-theme popover background too dark, wants desaturated navy (#1))*
Two different popover families exist and they are styled inconsistently:
- **Bespoke panels** (visualizer controls, queue, privacy) deliberately use
`--deepdrft-panel-ground` (`#1a1c22`, a dark charcoal) for their dark-glass chrome. These
are *meant* to be dark in both modes — leave them.
- **MudBlazor default popovers** (selects, menus, tooltips, the share popover body) inherit
`--mud-palette-surface`. In light mode `Surface = #FAFAF8`, but elevation-overlay tinting +
the `--deepdrft-panel-ground` charcoal leaking through shared chrome is making them read
darker/muddier than intended. Daniel's ask — "a more desaturated navy" — says the *target*
isn't pure white; it's a **soft desaturated-navy surface**. There is no token for that today,
so each popover improvises.
The fix is a **dedicated theme-aware popover-surface token** (`--deepdrft-popover-surface`)
with a desaturated-navy value in light mode and the existing panel-ground in dark mode, bound
once at the MudPopover surface so every default popover picks it up.
---
## 3. The DRY remediation — token structure
The unifying move: **page/component CSS must bind theme-aware aliases, and any surface that
must invert gets a named alias in `deepdrft-tokens.css` (defined twice — `:root` + `.deepdrft-theme-dark`).**
No surface colour is hardcoded at the component level. This is exactly the Layer-B discipline
the token file's header already declares; the work is making the consumers obey it.
### New / clarified tokens (in `deepdrft-tokens.css`)
| Token | Light (`:root`) | Dark (`.deepdrft-theme-dark`) | Replaces |
|---|---|---|---|
| `--deepdrft-page-surface` | `var(--deepdrft-white)` | `var(--deepdrft-navy)` (ground) or `--deepdrft-navy-mid` (elevated) | the literal `--deepdrft-white` on neutral page sections |
| `--deepdrft-page-text` | `var(--deepdrft-navy)` | `var(--deepdrft-white)` | the literal `--deepdrft-navy` text on neutral sections |
| `--deepdrft-page-text-muted` | `var(--deepdrft-muted)` | `color-mix(... lighter)` | muted body/eyebrow text that must stay legible on dark |
| `--deepdrft-play-chip` | `var(--deepdrft-soft)` | `var(--deepdrft-green-accent)` | `.icon-container` background |
| `--deepdrft-play-glyph` | (current) | `var(--deepdrft-navy)` | play glyph colour in dark |
| `--deepdrft-play-chip-soft` | derived | `color-mix(green-accent ~30%, transparent)` | player-bar translucent variant (#6) |
| `--deepdrft-popover-surface` | desaturated navy (e.g. `color-mix(navy 8%, white)`) | `var(--deepdrft-panel-ground)` | MudPopover default surface (#1) |
Values above are *direction, not final*. Per project memory (decorative-palette contrast
targets the actual WCAG threshold for the element type — large text 3:1, pushing toward
vibrancy), the implementer should tune the exact mixes on screen; the **structure** is the
deliverable here, the hex is theirs to land.
### Why tokens, not per-component fixes
- **One source of truth per concept.** "Neutral page surface," "play chip," "popover surface"
each become *one* token. A future page that needs a neutral surface binds the token and
inverts for free — no new dark-mode rule to remember (the backfill-cliff smell the
*design-for-adaptability* memory warns against).
- **Neutrality to accents is structural, not vigilance-based.** Because only neutral-surface
sections get re-tokened and the decorative navy/green sections keep their explicit brand
colours, the inversion *cannot* accidentally flip a section that's meant to stay navy. The
classification is encoded in *which token a section binds*, not in a reviewer noticing.
- **Player-bar vs. hero divergence is expressible.** Cause 2 needs the same green in two
opacities. A `--deepdrft-play-chip` token + a `--deepdrft-play-chip-soft` override the
player-bar context sets means one green, two contexts, zero duplication.
### What stays untouched (the neutrality guardrail)
`.section-dark`, `.split-left`, `.cta-banner` (Home + About), `ReleaseHeroOverlay` dark-image
chrome, and the bespoke `--deepdrft-panel-ground` panels (visualizer/queue/privacy) keep their
explicit brand colours. They are decorative-by-design and already correct in both modes. The
remediation must **not** route them through the new neutral-surface tokens.
---
## 4. Track / wave breakdown (for clean dispatch)
Sequenced so the token layer lands first and the component re-pointing fans out behind it.
Tracks T2T4 are parallel once T1 is in.
### T1 — Token foundation *(cold-start prerequisite)*
Add the theme-aware tokens from §3 to `deepdrft-tokens.css` — each defined in **both** `:root`
and `.deepdrft-theme-dark`. No component consumes them yet; this is a pure additive token
slice. Tune the dark-mode values on screen. **Load-bearing for everything below.**
- Scope: `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` only.
- Acceptance: tokens resolve to the right value in each mode (verify via devtools); no visual
change yet (nothing binds them).
### T2 — Neutral-surface inversion *(Cause 1 → symptoms #3, #4)*
Re-point the neutral page-surface sections from constant source tokens to `--deepdrft-page-surface`
/ `--deepdrft-page-text` / `--deepdrft-page-text-muted`. **Classify first** — only the neutral
sections; leave `.section-dark` / `.split-left` / `.cta-banner` / hero-overlay alone.
- Scope: `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css`.
- Acceptance: in dark mode the Home hero-left, the medium grid, the footer, and the About
light sections render dark-surface/light-text; the navy and green accent sections are
visually unchanged; light mode is pixel-identical to today.
- Risk: the appbar already has dark-mode handling (`deepdrft-styles.css §5`); confirm the
footer/hero changes don't double-invert anything the appbar rules already cover.
### T3 — Play-chip theming *(Cause 2 → symptoms #5, #6)*
Re-point `.icon-container` background from `--deepdrft-soft` to `--deepdrft-play-chip`; set the
dark play glyph to `--deepdrft-play-glyph` (navy); in the **player-bar context only**, override
the chip to the translucent `--deepdrft-play-chip-soft`.
- Scope: `PlayStateIcon.razor.css` (+ a player-bar-scoped override, likely in
`AudioPlayerBar.razor.css` or a context class on the bar's `.icon-container`).
- Acceptance (dark mode): release-hero + Cut-track-row play chips are **moss-green with a navy
glyph**; the player-bar play button is the **same green but markedly less opaque**; light
mode unchanged. Confirm hover states still read.
- Note: `PlayStateIcon` is shared — verify the chip change is acceptable on **every** mount
(heroes, track rows, player bar) and that the player-bar override is the only context-specific
divergence.
### T4 — Popover surface token *(Cause 3 → symptom #1)*
Introduce `--deepdrft-popover-surface` and bind MudBlazor's default popover surface to it so
light-mode popovers read as soft desaturated-navy rather than the current too-dark muddle.
**Do not** touch the bespoke `--deepdrft-panel-ground` panels.
- Scope: `deepdrft-styles.css` (a `.mud-popover` / popover-surface rule binding the new token);
token already added in T1.
- Acceptance: light-mode default popovers (selects/menus/share body) render desaturated-navy;
dark-mode popovers unchanged; the visualizer/queue/privacy panels are untouched.
- Open question (resolve during T4): confirm whether the "too dark" popover is a MudBlazor
elevation-overlay artifact or panel-ground leakage — the fix differs slightly (override the
overlay tint vs. set the surface). One devtools inspection settles it; flagged so the
implementer checks rather than guesses.
### Dependency shape
`T1 → {T2, T3, T4}`. T1 is the only cold-start item. T2/T3/T4 are independent of each other
and can land in any order or in parallel once T1 is in. None of them touch source code, the
data layer, or the streaming seam — this is a pure CSS-token pass.
---
## 5. Open questions for Daniel
1. **Dark neutral-surface = ground or elevated?** Should the inverted Home/About/footer
surfaces be the navy *ground* (`--deepdrft-navy`, matching the site background — sections
dissolve into one continuous dark field) or *elevated* navy-mid (`--deepdrft-navy-mid`
sections read as distinct raised panels)? Recommend **ground** for the footer/hero (continuous
field, less busy) and let the medium-cards stay as bordered panels on that ground. This is a
taste call; flag for Daniel.
2. **Popover target colour (#1).** "Desaturated navy" — how far from white? Recommend a light
wash (`color-mix(navy ~8%, white)`) so it stays clearly a light-mode surface, not a dark one.
Confirm direction on screen.
3. Everything else (exact green opacity for the player-bar chip, exact muted-text mix) is a
tune-on-screen detail, not a decision gate.
These are the only items that change the shape of the work; the rest is mechanical.