11 Commits

Author SHA1 Message Date
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
26 changed files with 1116 additions and 95 deletions
+1 -1
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**: 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. - **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. - **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. - **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. - **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.
+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) ## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
**Landed:** 2026-06-19 on dev. **Landed:** 2026-06-19 on dev.
+14 -2
View File
@@ -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. - **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. - 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]) ### 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). 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. - `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. - `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. - `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
- `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 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. - 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. - `[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`). - `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 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails. - 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]) ### DELETE api/track/{id:long} ([ApiKeyAuthorize])
+41 -3
View File
@@ -96,6 +96,37 @@ public class TrackController : ControllerBase
return Ok(result.Value); 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) // GET api/track/genres (unauthenticated)
// Distinct non-null genres with track counts. Public browse data, same posture as GET // 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. // api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
@@ -220,6 +251,7 @@ public class TrackController : ControllerBase
[FromForm] string? releaseType, [FromForm] string? releaseType,
[FromForm] string? medium, [FromForm] string? medium,
[FromForm] int? trackNumber, [FromForm] int? trackNumber,
[FromForm] long? releaseId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}", _logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
@@ -315,6 +347,7 @@ public class TrackController : ControllerBase
parsedReleaseType, parsedReleaseType,
parsedMedium, parsedMedium,
resolvedTrackNumber, resolvedTrackNumber,
releaseId,
cancellationToken); cancellationToken);
if (!result.Success || result.Value is null) if (!result.Success || result.Value is null)
@@ -322,14 +355,19 @@ public class TrackController : ControllerBase
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio"; var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error); _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 // A cardinality or duplicate-release rejection is a well-formed request that violates a
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is // domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
// stripped so the client sees only the human-readable detail. // The marker is stripped so the client sees only the human-readable detail.
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal)) if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
{ {
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]); return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
} }
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
{
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
}
return StatusCode(500, error); return StatusCode(500, error);
} }
+6
View File
@@ -18,6 +18,12 @@
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" /> <PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
</ItemGroup> </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> <ItemGroup>
<ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" /> <ProjectReference Include="..\DeepDrftModels\DeepDrftModels.csproj" />
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" /> <ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
+104 -28
View File
@@ -25,6 +25,16 @@ public class UnifiedTrackService
/// follows the marker and is what the CMS surfaces to the admin. /// follows the marker and is what the CMS surfaces to the admin.
/// </summary> /// </summary>
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: "; 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 TrackContentService _contentTrackContentService;
private readonly ITrackService _sqlTrackService; private readonly ITrackService _sqlTrackService;
private readonly FileDb _fileDatabase; private readonly FileDb _fileDatabase;
@@ -64,33 +74,66 @@ public class UnifiedTrackService
ReleaseType releaseType, ReleaseType releaseType,
ReleaseMedium medium, ReleaseMedium medium,
int trackNumber, int trackNumber,
long? releaseId,
CancellationToken ct) CancellationToken ct)
{ {
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans // Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we // orphans audio. Two paths:
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the // - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
// find path can violate: a release that does not yet exist has zero tracks and admits its // NOT already exist — the upload form creates new releases only. A pre-existing match is a
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so // duplicate and is blocked (409).
// a future bounded medium is covered by the same line. // - 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)) if (!string.IsNullOrWhiteSpace(album))
{ {
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct); if (releaseId is { } attachId)
if (!peek.Success)
{ {
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error"; var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error); if (!attachPeek.Success)
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}"); {
} 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) // 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.
var cardinality = MediumRules.CardinalityOf(existing.Medium); if (attachPeek.Value is not { } target || target.Id != attachId)
if (existing.TrackCount + 1 > cardinality.Max)
{ {
return ResultContainer<TrackDto>.CreateFailResult( return ResultContainer<TrackDto>.CreateFailResult(
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " + $"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
$"'{existing.Title}' already has one — edit the existing track or choose a different release."); "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 // 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) // a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
// rides on the release, not the track. // rides on the release, not the track.
long? releaseId = null; long? resolvedReleaseId = resolved?.Id;
if (!string.IsNullOrWhiteSpace(album)) 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 var releaseData = new ReleaseDto
{ {
Title = album, Title = album,
@@ -124,13 +170,13 @@ public class UnifiedTrackService
CreatedByUserId = createdByUserId, CreatedByUserId = createdByUserId,
}; };
// Medium (like every other field in releaseData) applies only when this upload CREATES the // FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first // row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a // duplicate peek above already verified no pre-existing row exists — so WasCreated = false
// subsequent track add: medium is a release-level property, changed only via the edit path // means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
// (PUT api/track/meta), never silently flipped by adding a track to an existing release. // rather than silently attaching, keeping the DB unique index as the final safety net.
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct); 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"; var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
_logger.LogError( _logger.LogError(
@@ -139,11 +185,21 @@ public class UnifiedTrackService
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}"); 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); 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. trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
var saveResult = await _sqlTrackService.Create(trackDto); var saveResult = await _sqlTrackService.Create(trackDto);
@@ -166,6 +222,26 @@ public class UnifiedTrackService
return saveResult; 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> /// <summary>
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes /// 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 /// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
+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.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.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). - `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) ## 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 /// 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 /// 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. /// 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> /// </summary>
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease( Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default); string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
/// <summary> /// <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) string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
{ {
try try
{ {
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (existing is not null) 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 // 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. // in releaseData so a typo upstream cannot create a release that won't be found again.
@@ -186,21 +186,21 @@ public class TrackManager
try try
{ {
var added = await Repository.AddReleaseAsync(entity, cancellationToken); 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) catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
{ {
// Concurrent upload inserted the same (title, artist) between our read and write. // 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 // Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
// constraint just fired, but re-throw if it does so the caller sees an error. // CREATE path) treats the lost race as a duplicate rather than silently attaching.
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken); var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
if (race is null) throw; if (race is null) throw;
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race)); return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
} }
} }
catch (Exception e) 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)) if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
{ {
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release); 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."; var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
return ResultContainer<TrackDto>.CreateFailResult(error); return ResultContainer<TrackDto>.CreateFailResult(error);
} }
newTrack.ReleaseId = resolved.Value.Id; newTrack.ReleaseId = resolved.Value.Release.Id;
} }
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack)); var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
@@ -146,6 +146,9 @@
private string _releaseDate = string.Empty; private string _releaseDate = string.Empty;
private ReleaseType _releaseType = ReleaseType.Single; private ReleaseType _releaseType = ReleaseType.Single;
private ReleaseMedium _medium = ReleaseMedium.Cut; 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 / // 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 // UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
@@ -214,6 +217,10 @@
} }
var release = tracks[0].Release; 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; _albumName = albumName;
_artist = release?.Artist ?? string.Empty; _artist = release?.Artist ?? string.Empty;
_genre = release?.Genre ?? string.Empty; _genre = release?.Genre ?? string.Empty;
@@ -592,6 +599,7 @@
_releaseType, _releaseType,
trackNumber, trackNumber,
_medium, _medium,
_releaseId,
progress); progress);
if (!uploadResult.Success || uploadResult.Value is null) if (!uploadResult.Success || uploadResult.Value is null)
@@ -298,6 +298,29 @@
return; 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 — // 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. // no separate Track Name input is shown. Sync here so the stored name always matches.
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0) if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
@@ -327,6 +350,11 @@
} }
int succeeded = 0, failed = 0; 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++) for (int i = 0; i < _tracks.Count; i++)
{ {
var row = _tracks[i]; var row = _tracks[i];
@@ -375,6 +403,7 @@
_releaseType, _releaseType,
trackNumber, trackNumber,
_medium, _medium,
batchReleaseId,
progress); progress);
if (!result.Success || result.Value is null) if (!result.Success || result.Value is null)
@@ -387,6 +416,15 @@
} }
else 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 // 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. // a follow-up metadata update — same two-step pattern BatchEdit uses.
if (_imagePath is { } imgPath) if (_imagePath is { } imgPath)
@@ -487,7 +525,13 @@
} }
else 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. // Stay on page so the admin can see the failed rows.
} }
} }
@@ -68,6 +68,7 @@ public class CmsTrackService : ICmsTrackService
ReleaseType releaseType, ReleaseType releaseType,
int trackNumber, int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut, ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null, IProgress<long>? progress = null,
CancellationToken ct = default) 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 // 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. // for an unrecognised value). Authoritative only when this upload creates the release.
multipart.Add(new StringContent(medium.ToString()), "medium"); 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}"); var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
if (send.Response is not { } response) 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) private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
{ {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp" "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 /// 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 /// 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. /// 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> /// </summary>
Task<ResultContainer<TrackDto>> UploadTrackAsync( Task<ResultContainer<TrackDto>> UploadTrackAsync(
Stream wavStream, Stream wavStream,
@@ -42,9 +46,20 @@ public interface ICmsTrackService
ReleaseType releaseType, ReleaseType releaseType,
int trackNumber, int trackNumber,
ReleaseMedium medium = ReleaseMedium.Cut, ReleaseMedium medium = ReleaseMedium.Cut,
long? releaseId = null,
IProgress<long>? progress = null, IProgress<long>? progress = null,
CancellationToken ct = default); 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> /// <summary>
/// Delete a track via the Content API, which removes the SQL row then the vault entry. /// 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. /// Maps a 404 to a "Track not found." failure.
+2 -2
View File
@@ -18,7 +18,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AppNavLink.razor`: Nav link with active-page highlight. - `AppNavLink.razor`: Nav link with active-page highlight.
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`. - `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). - `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/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/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state. - `AudioPlayerBar/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. - `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. - `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. - `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. - `Services/`: Audio player + dark-mode services.
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI. - `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume). - `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
@@ -26,7 +26,7 @@ else
SkipNext="@SkipNext" SkipNext="@SkipNext"
SkipPrevious="@SkipPrevious" SkipPrevious="@SkipPrevious"
ShowQueueButton="ShowQueueButton" ShowQueueButton="ShowQueueButton"
QueueOpen="_queueOpen" QueueOpen="QueueButtonOpen"
QueueToggle="@ToggleQueue" QueueToggle="@ToggleQueue"
Class="transport-zone"/> Class="transport-zone"/>
@@ -42,6 +42,23 @@ else
Class="seek-zone"/> Class="seek-zone"/>
</div> </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 *@ @* Minimize / close — positioned absolutely top-right *@
@if (!Fixed) @if (!Fixed)
{ {
@@ -62,8 +79,8 @@ else
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in @* 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 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. *@ the Fixed embed renders its own inline panel inside the surface above. *@
@if (ShowQueueButton) @if (ShowDockedOverlay)
{ {
<QueueOverlay Visible="_queueOpen" <QueueOverlay Visible="_queueOpen"
Items="QueueItems" Items="QueueItems"
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
private IJSObjectReference? _spacerModule; private IJSObjectReference? _spacerModule;
private bool _spacerObserved; 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 IsLoaded => PlayerService?.IsLoaded ?? false;
private bool IsLoading => PlayerService?.IsLoading ?? 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 HasNext => QueueService?.HasNext ?? false;
private bool HasPrevious => QueueService?.HasPrevious ?? false; private bool HasPrevious => QueueService?.HasPrevious ?? false;
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue, // Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue // skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only. // single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0; // 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 IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1; 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> /// <summary>
/// Display time - shows seek position while dragging, otherwise current playback time. /// Display time - shows seek position while dragging, otherwise current playback time.
/// </summary> /// </summary>
@@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
await QueueService.Previous(); 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; private void CloseQueue() => _queueOpen = false;
@@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
protected override async Task OnAfterRenderAsync(bool firstRender) 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 // For the docked player: we observe in BOTH expanded and minimized states
// so --player-height always reflects the live height of whichever element // so --player-height always reflects the live height of whichever element
// is visible. This keeps the WaveformVisualizer clipped to the top of // 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) // minimized → observe _miniDock (floating FAB container, ~5660px)
// The player-spacer's .minimized class uses a hardcoded height and ignores // 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. // the var, so publishing the FAB height here does not regress the spacer.
if (Fixed) return;
var elementToObserve = _isMinimized ? _miniDock : _playerRoot; var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id; 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); private async Task Expand() => await SetMinimized(false);
/// <summary> /// <summary>
@@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
} }
_spacerModule = null; _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> /// <summary>
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track /// 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="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome /// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here. ///
/// <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. /// Pure string composition so the snippet shape is unit-testable without rendering the component.
/// </summary> /// </summary>
public static class EmbedSnippetBuilder 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. // baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
public static string ForTrack(string baseUri, string trackEntryKey) 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) 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) private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>"""; => $"""<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"> <ul class="deepdrft-footer-links">
<li><a href="/about">About</a></li> <li><a href="/about">About</a></li>
<li><a href="#">Contact</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> </ul>
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div> <div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
</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; padding: 0;
} }
.deepdrft-footer-links a { .deepdrft-footer-links a,
.deepdrft-footer-links button {
font-family: var(--deepdrft-font-mono); font-family: var(--deepdrft-font-mono);
font-size: 0.62rem; font-size: 0.62rem;
letter-spacing: 0.18em; letter-spacing: 0.18em;
@@ -48,7 +49,8 @@
transition: color 0.2s; 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 { .deepdrft-footer-copy {
font-family: var(--deepdrft-font-mono); font-family: var(--deepdrft-font-mono);
@@ -57,14 +59,16 @@
color: var(--deepdrft-muted); color: var(--deepdrft-muted);
} }
.deepdrft-footer-privacy { /* PRIVACY trigger reset button chrome so it reads as a link, not a button element.
font-family: var(--deepdrft-font-mono); Typography/colour shared with footer <a> links via the grouped selector above. */
font-size: 0.55rem; .deepdrft-footer-privacy-btn {
letter-spacing: 0.08em; background: none;
color: var(--deepdrft-muted); border: none;
opacity: 0.7; padding: 0;
margin: 0; margin: 0;
line-height: 1.6; cursor: pointer;
line-height: inherit;
font-family: inherit;
} }
@media (max-width: 440px) { @media (max-width: 440px) {
@@ -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); background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
border-radius: 6px; 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>
<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="..\DeepDrftContent\DeepDrftContent.csproj" />
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" /> <ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain <!-- 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; using DeepDrftPublic.Client.Helpers;
namespace DeepDrftTests; namespace DeepDrftTests;
@@ -5,8 +6,9 @@ namespace DeepDrftTests;
/// <summary> /// <summary>
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is /// 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 /// 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 /// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but
/// across both. Pure string composition, tested directly without rendering the component. /// 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> /// </summary>
[TestFixture] [TestFixture]
public class EmbedSnippetBuilderTests public class EmbedSnippetBuilderTests
@@ -27,12 +29,13 @@ public class EmbedSnippetBuilderTests
{ {
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); 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")); Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
} }
[Test] [Test]
public void BothModes_ShareIdenticalIframeChrome() public void BothModes_ShareIdenticalNonHeightChrome()
{ {
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k"); var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
var release = EmbedSnippetBuilder.ForRelease(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.StartWith("<iframe "));
Assert.That(snippet, Does.Contain(@"width=""656""")); 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(@"frameborder=""0"""));
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;""")); Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
Assert.That(snippet, Does.Contain(@"allow=""autoplay""")); 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)); "Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
Assert.That(result.Success, Is.True); 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)); Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
} }
@@ -75,7 +75,7 @@ public class MediumWritePathTests
var result = await manager.FindOrCreateRelease( var result = await manager.FindOrCreateRelease(
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix)); "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. // 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( var result = await manager.FindOrCreateRelease(
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut)); "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 // 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( var found = await manager.FindOrCreateRelease(
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut)); "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.Release.Id, Is.EqualTo(created.Value.Release.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.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"); 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); var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
Assert.That(result.Success, Is.True); 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)); 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();
}
}
+9 -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 `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 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 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 members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. **Landed
pending. (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 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`. the open-question set: `product-notes/phase-17-player-queue-view.md`.
**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).** **Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**
- **OQ1****Option A, conditional** — collapse/expand toggle *if* the embed snippet can dynamically - **OQ1****Option A, confirmed (17.3)** — collapse/expand toggle with `postMessage` → host resize
resize the iframe (`postMessage` → host resize handshake), **else fall back to Option B** (omit the handshake implemented. `EmbedSnippetBuilder.ForRelease` carries the host-side listener; `embed-frame.ts`
button); A preferred, B fallback, deciding factor = iframe-resize feasibility, **determined during posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
17.3**.
- **OQ2****yes, both modes** — clicking a queued row jumps playback to that track in the docked - **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)`. overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove - **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove