Compare commits
11 Commits
fbd298b9c3
...
261b11436e
| Author | SHA1 | Date | |
|---|---|---|---|
| 261b11436e | |||
| 280dbbcbc9 | |||
| ce17a685e0 | |||
| 64379c8901 | |||
| 1f8802363c | |||
| 58cdb4d9dc | |||
| 97cce691db | |||
| d0be26bb3e | |||
| 466084b5a3 | |||
| 558ff4b4c6 | |||
| bd85507308 |
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, ~56–60px)
|
// minimized → observe _miniDock (floating FAB container, ~56–60px)
|
||||||
// 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>&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’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’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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user