13 Commits

Author SHA1 Message Date
daniel-c-harvey 33383cd675 Merge p22-w2-jsonld-type-fix into dev
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m20s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m25s
Fix JSON-LD @type serialization: concrete nodes were emitting a bare Type alongside @type because the attribute sat only on the abstract base override. Validator now clean.
2026-06-23 06:57:44 -04:00
daniel-c-harvey 56f7013314 fix: put [JsonPropertyName("@type")] on each concrete JsonLdNode override
System.Text.Json emitted both "@type" and a bare "Type" because the attribute was only on the abstract base member. Adds regression assertions for all node types.
2026-06-23 06:57:05 -04:00
daniel-c-harvey 2653e62eeb docs: reflect Phase 22 SEO metadata component as landed 2026-06-23 06:21:52 -04:00
daniel-c-harvey 45bd599bdd Merge p22-w1-seo-metadata-component into dev
Phase 22: parameterized SEO metadata component for the public site — SeoHead + typed JSON-LD builders, per-medium release schema, env-gated noindex (beta uncrawled), inline-safe JSON-LD escaping.
2026-06-23 06:16:31 -04:00
daniel-c-harvey f976af0f7c fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex
Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
2026-06-23 06:10:03 -04:00
daniel-c-harvey f3b89ca9d7 feature: Phase 22 SEO metadata component for public site
One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
2026-06-23 05:41:55 -04:00
daniel-c-harvey 8752fc0c98 docs: resolve Phase 18 OQ7 seek-index granularity to 0.5s buckets 2026-06-23 05:36:25 -04:00
daniel-c-harvey 274d0ace62 Merge install-prep-analysis: installer prompts for AuthBlocks:Email:From 2026-06-23 05:28:17 -04:00
daniel-c-harvey e3a4364b8c docs(plan): Phase 18 OQ resolutions + VBR-safe accurate Opus seek model 2026-06-23 05:26:58 -04:00
daniel-c-harvey 564b704803 fix(installer): prompt for and write AuthBlocks:Email:From
Without this field, DeepDrftAPI throws InvalidOperationException on
startup. Adds the EMAIL_FROM prompt after EMAIL_TOKEN, writes "From"
into the Email JSON object, and unsets the variable on cleanup.
2026-06-23 05:26:48 -04:00
daniel-c-harvey 6af6677a12 docs: spec Phase 22 — parameterized SEO metadata component (public site) 2026-06-23 05:12:31 -04:00
daniel-c-harvey 1bdaeaa164 docs(plan): add Phase 18 Opus low-data streaming; resolve Phase 21 OQ5 (no MSE) 2026-06-23 04:58:21 -04:00
daniel-c-harvey a84a99c309 docs: spec Phase 21 — windowed streaming buffer for bounded client memory 2026-06-23 00:14:44 -04:00
28 changed files with 2977 additions and 15 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
### Core Projects ### Core Projects
- **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`. **SEO component** (`Controls/SeoHead.razor` + `Common/SeoModel`, `SeoJsonLd`, `SeoOptions`, `SeoUrls`, `SeoEnvironment`): `SeoHead` is a presentational `<HeadContent>` emitter (one line per page, no fetch); `SeoModel` named factories (`ForRelease`/`ForHome`/`ForAbout`/`ForBrowse`/`ForNotFound`) encode the medium→schema.org mapping in one place; `SeoJsonLd` builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; `SeoOptions` holds site-wide config (`BaseUrl https://deepdrft.com`, title suffix, default OG image seam, IG `sameAs`) registered via the static `Startup` seam; `SeoEnvironment` is a scoped `[PersistentState]` bridge (mirrors `DarkModeSettings`) seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — robots defaults to `index,follow` only in Production, `noindex,nofollow` everywhere else (fail-safe is noindex); per-page `SeoModel.Robots` overrides the default. Tags are present in prerendered HTML (rides the existing `PersistentComponentState` bridge; no new fetch). Canonical/OG origins come from `SeoOptions.BaseUrl` (config), not `window.location` — no `window` at server prerender and the origin cannot be derived behind the nginx proxy. 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`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. 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. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. 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. - **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`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. 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. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. 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.
+22
View File
@@ -6,6 +6,28 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
--- ---
## Phase 22 — SEO Metadata Component (landed 2026-06-23)
**Landed:** 2026-06-23 on dev.
- **What:** A parameterized, reusable SEO head component (`SeoHead.razor`) that emits the full modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD — for every public page in one line of markup. **Public listener site only** (`DeepDrftPublic` host + `DeepDrftPublic.Client`); the CMS is explicitly out of scope. No data-model/schema change, no new API endpoint.
- **Why:** `App.razor` had a static `<head>` with no description, canonical, OG, Twitter Card, or JSON-LD anywhere; pages set only an ad-hoc `<PageTitle>` with an inconsistent suffix. A shared `/mixes/{key}` link unfurled as a bare title + URL. Crawlers and social unfurlers saw nothing useful.
- **Shape:**
- **`Controls/SeoHead.razor`** (new): purely presentational `<HeadContent>` + `<PageTitle>` emitter. Accepts a single `SeoModel` parameter; owns no data fetch. Each page wires it in one line.
- **`Common/SeoModel.cs`** (new): typed per-page input with named factories — `ForRelease(release, baseUrl, options)` (medium-dispatched), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Factories encode the medium→schema mapping in one place. Explicit `SeoModel.Robots` override available; default is environment-gated (see `SeoEnvironment`).
- **`Common/SeoJsonLd.cs`** (new): typed schema.org JSON-LD builders. Cut → `MusicAlbum` with ordered `MusicRecording` track list; Session → `MusicAlbum`/`LiveAlbum`; Mix → single `MusicRecording` with ISO-8601 duration; Home/About → `MusicGroup` (with `sameAs: ["https://instagram.com/deepdrft.music"]`); Browse → `CollectionPage`. `byArtist` wired per-release. JSON-LD body is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- **`Common/SeoOptions.cs`** (new): site-wide config — `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (runs in both server and WASM `Program.cs`).
- **`Common/SeoUrls.cs`** (new): URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (config, not `window.location` — no `window` at server prerender and the origin can't be derived behind the nginx proxy).
- **`Common/SeoEnvironment.cs`** (new): scoped `[PersistentState]` bridge seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
- **Wired into:** Home, About, Cut/Session/Mix detail pages (incl. their not-found branches → `noindex`), the browse views (Albums/Sessions/Mixes/Archive), and the 404 NotFound page.
- **Render-mode correctness:** `SeoHead` rides the existing `PersistentComponentState` bridge (the same `ReleaseDto` the detail pages already bridge) — no new fetch. The `InteractiveAuto` double-render produces identical head content across prerender and WASM passes (fed from bridged state, guarded on id/key equality).
- **Design memo:** `product-notes/phase-22-seo-metadata-component.md`.
---
## Phase 20 — Theater Mode (landed 2026-06-20) ## Phase 20 — Theater Mode (landed 2026-06-20)
**Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev. **Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev.
+8
View File
@@ -40,6 +40,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred). - `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active. - `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold. The scaffold's default masthead PLAY (`PlayTrack`) routes through `IQueueService.PlayTrack` (deque PLAY semantics — prepends the track to the queue front) when the queue cascade is present, falling back to `IStreamingPlayerService.SelectTrackStreaming` when absent; toggle-pause is handled directly via `IStreamingPlayerService.TogglePlayPause` when this track is already active.
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay. - `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
- `SeoHead.razor`: Purely presentational SEO head emitter (Phase 22). Renders a `<PageTitle>` + `<HeadContent>` block from a single `SeoModel` parameter — standard meta (description, robots), canonical, Open Graph, Twitter Card, and schema.org JSON-LD. Owns no data fetch; each page wires it in one line and supplies the model from its already-bridged ViewModel state. Wired on Home, About, Cut/Session/Mix detail (incl. not-found branches → `noindex`), browse views, and the 404 page.
- `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.
@@ -70,6 +71,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
- `Common/`: Shared utilities. - `Common/`: Shared utilities.
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped. - `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B). - `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
- `SeoModel.cs`: Typed per-page SEO input (Phase 22). Named factories: `ForRelease` (medium-dispatched — Cut → `MusicAlbum`, Session → `MusicAlbum`/`LiveAlbum`, Mix → `MusicRecording`), `ForHome`, `ForAbout`, `ForBrowse`, `ForNotFound`. Encodes the medium→schema.org mapping in one place. `SeoModel.Robots` overrides the environment-default (see `SeoEnvironment`).
- `SeoJsonLd.cs`: Typed schema.org JSON-LD builders (Phase 22). Types: `MusicGroup` (home/about, with `sameAs: ["https://instagram.com/deepdrft.music"]`), `MusicAlbum`/`LiveAlbum` (cuts/sessions, with ordered `MusicRecording` track list and per-release `byArtist`), `MusicRecording` (mixes, with ISO-8601 `duration`), `CollectionPage` (browse). All serialized output is inline-safe-escaped (`<`/`>`/`&``\uXXXX`) to prevent script-breakout from CMS-authored text.
- `SeoOptions.cs`: Site-wide SEO config (Phase 22). `BaseUrl` (`https://deepdrft.com`), title suffix (`Deep DRFT`, middot separator), default OG image seam (uses `ImageProxyController` route), IG handle in `sameAs`, no Twitter handle. Registered via the static `Startup` seam (both server and WASM `Program.cs`). `BaseUrl` is config, not `window.location` — no `window` at server prerender, and the origin cannot be derived reliably behind the nginx proxy.
- `SeoUrls.cs`: URL helpers for canonical and `og:image` construction from `SeoOptions.BaseUrl` (Phase 22).
- `SeoEnvironment.cs`: Scoped `[PersistentState]` bridge for the server environment flag (Phase 22). Seeded in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` — mirrors the `DarkModeSettings` bridge pattern. Default robots is `index,follow` only in Production; `noindex,nofollow` in every non-production environment so the beta/staging site stays uncrawled. Explicit per-page `SeoModel.Robots` overrides this default. Fail-safe default is `noindex`.
- `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`. - `Program.cs`: WASM entry point. Calls `Startup.ConfigureApiHttpClient`, `ConfigureContentServices`, `ConfigureDomainServices`.
- `_Imports.razor`: Global using statements and component imports. - `_Imports.razor`: Global using statements and component imports.
@@ -129,6 +135,8 @@ New modules in `DeepDrftPublic/Interop/audio/`:
The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie. The flow ensures the first paint uses the correct theme (no flash), and toggling the button persists the setting to a 365-day cookie.
**`SeoEnvironment` follows the same `[PersistentState]` bridge pattern** (Phase 22). It is seeded server-side in `DeepDrftPublic/Components/App.razor` from `IWebHostEnvironment.IsProduction()` and bridged to the WASM client. Consumers (`SeoHead`) read `SeoEnvironment.IsProduction` to gate the default robots directive (`index,follow` in Production, `noindex,nofollow` elsewhere). The pattern is identical to `DarkModeSettings` — one server-side seed, one `PersistentComponentState` round-trip, one scoped client read.
## MVVM convention ## MVVM convention
Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only. Component state lives in ViewModels (registered scoped in DI). Components render and dispatch only.
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
/// not be crawled, so the <i>default</i> robots directive is environment-gated: <c>index,follow</c> only in
/// Production, <c>noindex,nofollow</c> everywhere else. A per-page <see cref="SeoModel.Robots"/> override
/// still wins — this only sets the default.
///
/// <para>
/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
/// the same flag. The WASM assembly has no <c>IWebHostEnvironment</c> (config comes from the server). This
/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
/// <c>IWebHostEnvironment.IsProduction()</c>) and <c>[PersistentState]</c> rounds to the client, so both
/// passes resolve the identical value. <c>SeoHead</c> injects this rather than an environment dependency,
/// honouring the no-environment-in-the-component constraint.
/// </para>
/// </summary>
public class SeoEnvironment
{
/// <summary>
/// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
/// <c>false</c> so the fail-safe is "do not index" — a missing bridge never accidentally opens a
/// non-production site to crawlers.
/// </summary>
[PersistentState]
public bool IsProduction { get; set; }
/// <summary>The environment-gated default robots directive. Explicit page values override this.</summary>
public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
}
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
/// schema.org type; <see cref="SeoJsonLd.Serialize"/> renders a node to the <c>&lt;script type="application/ld+json"&gt;</c>
/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping
/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass.
///
/// <para>
/// All nodes share <see cref="JsonLdNode"/> so the <c>@context</c>/<c>@type</c> pair serialises first and
/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty
/// or broken node (C6/AC4).
/// </para>
/// </summary>
public static class SeoJsonLd
{
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
// each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
// Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
// body is injected into the <script> element. See Serialize.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false,
};
/// <summary>
/// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
/// The body is run through <see cref="InlineSafe"/> so CMS-authored values containing
/// <c>&lt;/script&gt;</c> or <c>&lt;</c> cannot break out of the inline script element (XSS).
/// </summary>
public static string Serialize<TNode>(TNode node) where TNode : JsonLdNode =>
InlineSafe(JsonSerializer.Serialize(node, node.GetType(), Options));
/// <summary>
/// Escapes the three characters that can break out of an inline <c>&lt;script type="application/ld+json"&gt;</c>
/// element. Replacing <c>&lt;</c>/<c>&gt;</c>/<c>&amp;</c> with their <c>\uXXXX</c> JSON escapes keeps the
/// JSON byte-for-byte equivalent on parse (a JSON string treats <c><</c> and <c>&lt;</c> identically)
/// while making <c>&lt;/script&gt;</c> impossible to emit raw — the documented safe pattern for inline JSON-LD.
/// </summary>
internal static string InlineSafe(string json) => json
.Replace("<", "\\u003C")
.Replace(">", "\\u003E")
.Replace("&", "\\u0026");
}
/// <summary>Base for every schema.org node: emits <c>@context</c> and <c>@type</c> first.</summary>
public abstract record JsonLdNode
{
[JsonPropertyName("@context")]
[JsonPropertyOrder(-2)]
public string Context => "https://schema.org";
[JsonPropertyName("@type")]
[JsonPropertyOrder(-1)]
public abstract string Type { get; }
}
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
public sealed record MusicGroupNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")] public string? Url { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("logo")] public string? Logo { get; init; }
[JsonPropertyName("sameAs")] public IReadOnlyList<string>? SameAs { get; init; }
}
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
public sealed record MusicAlbumNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicAlbum";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>schema.org <c>MusicAlbumProductionType</c> URI, e.g. <c>StudioAlbum</c> or <c>LiveAlbum</c>.</summary>
[JsonPropertyName("albumProductionType")] public string? AlbumProductionType { get; init; }
[JsonPropertyName("datePublished")] public string? DatePublished { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
/// <summary>Ordered list of the album's recordings (cut track list, in TrackNumber order).</summary>
[JsonPropertyName("track")] public IReadOnlyList<MusicRecordingNode>? Track { get; init; }
}
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
public sealed record MusicRecordingNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "MusicRecording";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from <c>DurationSeconds</c>.</summary>
[JsonPropertyName("duration")] public string? Duration { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
public sealed record CollectionPageNode : JsonLdNode
{
[JsonPropertyName("@type")] public override string Type => "CollectionPage";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A nested <c>byArtist</c> reference — the collective as a MusicGroup, by name.</summary>
public sealed record ArtistRef
{
[JsonPropertyName("@type")] public string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
}
+209
View File
@@ -0,0 +1,209 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The OG <c>og:type</c> for a page. Releases map per medium (§3.4); everything else is a website.
/// </summary>
public enum SeoOgType
{
Website,
MusicAlbum,
MusicSong,
}
/// <summary>
/// The typed per-page SEO input (Phase 22). A page hands <c>SeoHead</c> one model instead of ~15 loose
/// parameters; the named factories below encode the per-page / per-medium mapping (title, description,
/// canonical path, og:type, JSON-LD node) in exactly one place each (DRY, §4.1/§4.2). The factories are
/// pure functions over DTOs the page already holds — unit-testable without rendering.
///
/// <para>
/// <see cref="CanonicalPath"/> is site-relative; <c>SeoHead</c> absolutises it against
/// <see cref="SeoOptions.BaseUrl"/>. Release pages pass <see cref="ReleaseRoutes.DetailHref"/> so the
/// canonical is the dedicated route regardless of alias/query routes (AC7). A null cover means the model
/// carries no <see cref="ImagePath"/> and <c>SeoHead</c> falls back to the default OG image (C6/AC4).
/// </para>
/// </summary>
public sealed record SeoModel
{
/// <summary>Bare page title, no site suffix. <c>SeoHead</c> composes <c>"{Title} · {suffix}"</c>.</summary>
public required string Title { get; init; }
/// <summary>Meta/OG description. Null falls back to <see cref="SeoOptions.DefaultDescription"/>.</summary>
public string? Description { get; init; }
/// <summary>Site-relative canonical path. Null defaults to the current path in <c>SeoHead</c>.</summary>
public string? CanonicalPath { get; init; }
/// <summary>Relative cover <c>ImagePath</c>. Null → the default OG image.</summary>
public string? ImagePath { get; init; }
public SeoOgType OgType { get; init; } = SeoOgType.Website;
/// <summary>Robots directive. Null falls back to <see cref="SeoOptions.DefaultRobots"/>.</summary>
public string? Robots { get; init; }
/// <summary>Pre-serialised JSON-LD script body, or null to emit no structured-data script.</summary>
public string? JsonLd { get; init; }
// --- Music-vertical OG, release pages only (null elsewhere → tags omitted) ---
public string? Artist { get; init; }
public DateOnly? ReleaseDate { get; init; }
public double? DurationSeconds { get; init; }
// ------------------------------------------------------------------ Factories
/// <summary>Home page: the collective entity (MusicGroup JSON-LD), site-level OG.</summary>
public static SeoModel ForHome(SeoOptions options) => new()
{
Title = "Electronic Music Collective",
Description = options.DefaultDescription,
CanonicalPath = "/",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options)),
};
/// <summary>About page: the collective again, with the bio lede as description.</summary>
public static SeoModel ForAbout(SeoOptions options) => new()
{
Title = "The Collective",
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
CanonicalPath = "/about",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options) with
{
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
}),
};
/// <summary>A browse surface: <c>CollectionPage</c> JSON-LD, website OG.</summary>
public static SeoModel ForBrowse(SeoOptions options, ReleaseMedium? medium, string path)
{
var (title, description) = BrowseCopy(medium);
return new SeoModel
{
Title = title,
Description = description,
CanonicalPath = path,
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(new CollectionPageNode
{
Name = title,
Description = description,
Url = SeoUrls.Absolute(options, path),
}),
};
}
/// <summary>The 404 page: no canonical, <c>noindex,follow</c>, no JSON-LD.</summary>
public static SeoModel ForNotFound(SeoOptions options) => new()
{
Title = "Not Found",
Description = options.DefaultDescription,
Robots = "noindex,follow",
OgType = SeoOgType.Website,
};
/// <summary>
/// A release detail page. The medium picks the schema (cut/session → MusicAlbum, mix → MusicRecording),
/// the og:type, and the music-vertical OG fields; the canonical is the dedicated route. The optional
/// <paramref name="tracks"/> seed the album's ordered <c>track</c> list (cut). <b>One call site, all tags.</b>
/// </summary>
public static SeoModel ForRelease(SeoOptions options, ReleaseDto release, IReadOnlyList<TrackDto>? tracks = null)
{
var canonicalPath = ReleaseRoutes.DetailHref(release.EntryKey, release.Medium);
var image = SeoUrls.CoverOrDefault(options, release.ImagePath);
// byArtist reflects the release's own artist, consistent with the music:musician OG tag (Daniel's
// call) — not the collective name. Album sub-recordings share it: the tracks are by this artist.
var artist = new ArtistRef { Name = release.Artist };
var description = string.IsNullOrWhiteSpace(release.Description) ? options.DefaultDescription : release.Description;
// A mix is a single recording; its duration comes from the (single) track when present.
var mixDurationSeconds = release.Medium == ReleaseMedium.Mix
? tracks?.FirstOrDefault()?.DurationSeconds
: null;
JsonLdNode node = release.Medium switch
{
ReleaseMedium.Mix => new MusicRecordingNode
{
Name = release.Title,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(mixDurationSeconds),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
},
// Cut and Session are both albums; the production type distinguishes a live session.
_ => new MusicAlbumNode
{
Name = release.Title,
ByArtist = artist,
AlbumProductionType = release.Medium == ReleaseMedium.Session
? "https://schema.org/LiveAlbum"
: "https://schema.org/StudioAlbum",
DatePublished = release.ReleaseDate?.ToString("yyyy-MM-dd"),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
Track = AlbumTracks(options, artist, tracks),
},
};
return new SeoModel
{
Title = release.Title,
Description = description,
CanonicalPath = canonicalPath,
ImagePath = release.ImagePath,
OgType = release.Medium == ReleaseMedium.Mix ? SeoOgType.MusicSong : SeoOgType.MusicAlbum,
Artist = release.Artist,
ReleaseDate = release.ReleaseDate,
DurationSeconds = mixDurationSeconds,
JsonLd = SeoJsonLd.Serialize(node),
};
}
// The collective entity, built once from config — the home/about JSON-LD root.
private static MusicGroupNode MusicGroup(SeoOptions options) => new()
{
Name = options.SiteName,
Url = SeoUrls.Absolute(options, "/"),
Genre = options.Genre,
Description = options.DefaultDescription,
Logo = SeoUrls.Absolute(options, options.DefaultImageUrl),
SameAs = options.SameAs.Count > 0 ? options.SameAs : null,
};
// Ordered recording list for an album's `track` property. Null when there are no tracks so the
// property is omitted rather than emitting an empty array (C6).
private static IReadOnlyList<MusicRecordingNode>? AlbumTracks(
SeoOptions options, ArtistRef artist, IReadOnlyList<TrackDto>? tracks)
{
if (tracks is null || tracks.Count == 0) return null;
return tracks
.OrderBy(t => t.TrackNumber)
.Select(t => new MusicRecordingNode
{
Name = t.TrackName,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(t.DurationSeconds),
})
.ToList();
}
private static (string Title, string Description) BrowseCopy(ReleaseMedium? medium) => medium switch
{
ReleaseMedium.Cut => ("Cuts", "Studio cuts from Deep DRFT — composed, layered, and finished."),
ReleaseMedium.Session => ("Sessions", "Live sessions from Deep DRFT — performances caught in the moment, unrepeatable and unedited."),
ReleaseMedium.Mix => ("Mixes", "DJ mixes from Deep DRFT — uninterrupted sets, one track bleeding into the next."),
_ => ("Archive", "The full Deep DRFT catalogue — cuts, sessions, and mixes, indexed and always expanding."),
};
}
@@ -0,0 +1,52 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Site-wide SEO defaults (Phase 22). These are non-secret brand constants — a single canonical origin,
/// the site name/suffix, the fallback share image, the social links — sourced once and injected into
/// <c>SeoHead</c> so no page re-declares them. Registered as a singleton in
/// <see cref="Startup.ConfigureDomainServices"/>, which runs in <b>both</b> the server prerender and the
/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
///
/// <para>
/// <see cref="BaseUrl"/> is the load-bearing field: absolute canonical / <c>og:url</c> / <c>og:image</c>
/// origins all come from here, never from a browser API — there is no <c>window.location</c> during
/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
/// </para>
/// </summary>
public sealed record SeoOptions
{
/// <summary>Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).</summary>
public string BaseUrl { get; init; } = "https://deepdrft.com";
/// <summary>The brand name used in <c>og:site_name</c>, <c>application-name</c>, and the JSON-LD MusicGroup.</summary>
public string SiteName { get; init; } = "Deep DRFT";
/// <summary>Appended to a page's bare title as <c>"{Title} · {TitleSuffix}"</c>. Resolves the prior suffix inconsistency (OQ4).</summary>
public string TitleSuffix { get; init; } = "Deep DRFT";
/// <summary>Fallback meta/OG description for pages that supply none.</summary>
public string DefaultDescription { get; init; } =
"Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
/// <summary>
/// Absolute or root-relative URL of the default 1200×630 share image used when a page has no cover (OQ2).
/// A placeholder path until the real asset is dropped in; swapping it is a one-value change.
/// </summary>
public string DefaultImageUrl { get; init; } = "/img/og-default.png";
/// <summary>OG locale. Optional surface tag.</summary>
public string Locale { get; init; } = "en_US";
/// <summary>The collective's primary genre, used in the MusicGroup JSON-LD node.</summary>
public string Genre { get; init; } = "Electronic";
// The default robots directive is NOT a static option — it is environment-gated (Production →
// index,follow; non-production → noindex,nofollow) via SeoEnvironment so the beta/staging site is
// never crawled. A page's explicit SeoModel.Robots still overrides that default.
/// <summary>
/// Public social profile URLs for the MusicGroup <c>sameAs</c> array (OQ3). Instagram only —
/// no Twitter/X account exists, so no <c>twitter:site</c>/<c>twitter:creator</c> handle is emitted.
/// </summary>
public IReadOnlyList<string> SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
}
+44
View File
@@ -0,0 +1,44 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Absolute-URL composition for SEO tags (Phase 22). Canonical / <c>og:url</c> / <c>og:image</c> origins
/// all come from <see cref="SeoOptions.BaseUrl"/> (config), never from a browser API — there is no
/// <c>window.location</c> during server prerender and the request host is unreliable behind nginx
/// (§5, OQ1). Shared by the <c>SeoModel</c> factories (which absolutise JSON-LD <c>url</c>/<c>image</c>)
/// and <c>SeoHead</c> (which absolutises the meta/OG tags) so the rule lives in exactly one place.
/// </summary>
public static class SeoUrls
{
/// <summary>BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.</summary>
public static string Absolute(SeoOptions options, string path)
{
var origin = options.BaseUrl.TrimEnd('/');
if (string.IsNullOrEmpty(path)) return origin;
return $"{origin}/{path.TrimStart('/')}";
}
/// <summary>
/// Absolute URL of a release/track cover from its FileDatabase <c>ImagePath</c>, via the public image
/// route (<c>api/image/{escaped}</c>). Returns the configured default share image when no cover exists
/// (C6/AC4 — a default guarantees <c>og:image</c> presence).
/// </summary>
public static string CoverOrDefault(SeoOptions options, string? imagePath)
{
if (string.IsNullOrWhiteSpace(imagePath))
return Absolute(options, options.DefaultImageUrl);
return Absolute(options, $"api/image/{Uri.EscapeDataString(imagePath)}");
}
/// <summary>
/// ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from a seconds value, for JSON-LD <c>duration</c> and the
/// <c>music:duration</c> OG tag. Null / non-finite / non-positive input yields null (omit the tag).
/// </summary>
public static string? IsoDuration(double? seconds)
{
if (seconds is null || double.IsNaN(seconds.Value) || double.IsInfinity(seconds.Value) || seconds.Value <= 0)
return null;
return System.Xml.XmlConvert.ToString(TimeSpan.FromSeconds(seconds.Value));
}
}
@@ -0,0 +1,116 @@
@using DeepDrftPublic.Client.Common
@inject SeoOptions Seo
@inject SeoEnvironment SeoEnv
@inject NavigationManager Nav
@*
The single reusable SEO head surface (Phase 22). Presentational and parameter-fed — owns no fetch and
no business logic (C4); it reads the injected SeoOptions for defaults and NavigationManager for the
current path. Renders <PageTitle> (the sole title source — pages drop their bare <PageTitle>) plus a
<HeadContent> block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
<HeadOutlet> in App.razor so it is present in the prerendered HTML a crawler sees (C2/AC1).
Identical output across the InteractiveAuto double render (AC6): every value comes from the parameter
Model (built from the page's bridged PersistentComponentState) and config — never a browser API — so
the prerender and WASM passes render byte-identical tags.
Partial data (C6/AC4): a missing value falls back to config or omits its tag; og:image always resolves
(the default guarantees presence) so there is never a content="" attribute or a broken node.
*@
<PageTitle>@_fullTitle</PageTitle>
<HeadContent>
@* Standard / search *@
<meta name="description" content="@_description" />
<link rel="canonical" href="@_canonical" />
<meta name="robots" content="@_robots" />
<meta name="application-name" content="@Seo.SiteName" />
@* Open Graph *@
<meta property="og:title" content="@Model.Title" />
<meta property="og:description" content="@_description" />
<meta property="og:url" content="@_canonical" />
<meta property="og:type" content="@_ogType" />
<meta property="og:site_name" content="@Seo.SiteName" />
<meta property="og:locale" content="@Seo.Locale" />
<meta property="og:image" content="@_image" />
@if (_hasCover)
{
<meta property="og:image:alt" content="@($"{Model.Title} cover art")" />
}
@* Music-vertical OG (release pages only) *@
@if (!string.IsNullOrWhiteSpace(Model.Artist))
{
<meta property="music:musician" content="@Model.Artist" />
}
@if (Model.ReleaseDate is not null)
{
<meta property="music:release_date" content="@Model.ReleaseDate.Value.ToString("yyyy-MM-dd")" />
}
@if (_isoDuration is not null)
{
<meta property="music:duration" content="@_isoDuration" />
}
@* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@Model.Title" />
<meta name="twitter:description" content="@_description" />
<meta name="twitter:image" content="@_image" />
@* JSON-LD structured data *@
@if (!string.IsNullOrEmpty(Model.JsonLd))
{
<script type="application/ld+json">@((MarkupString)Model.JsonLd)</script>
}
</HeadContent>
@code {
/// <summary>The page's resolved SEO input, built via a <see cref="SeoModel"/> factory.</summary>
[Parameter, EditorRequired] public required SeoModel Model { get; set; }
private string _fullTitle = string.Empty;
private string _description = string.Empty;
private string _canonical = string.Empty;
private string _robots = string.Empty;
private string _ogType = "website";
private string _image = string.Empty;
private bool _hasCover;
private string? _isoDuration;
protected override void OnParametersSet()
{
_fullTitle = $"{Model.Title} · {Seo.TitleSuffix}";
_description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description;
// Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
// crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots;
_ogType = OgTypeString(Model.OgType);
// Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is
// always config (no browser API) so prerender and WASM agree (§5).
var path = Model.CanonicalPath ?? RelativePath();
_canonical = SeoUrls.Absolute(Seo, path);
_hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath);
_image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath);
_isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds);
}
private string RelativePath()
{
var path = Nav.ToBaseRelativePath(Nav.Uri);
var query = path.IndexOf('?');
if (query >= 0) path = path[..query];
return "/" + path;
}
private static string OgTypeString(SeoOgType type) => type switch
{
SeoOgType.MusicAlbum => "music.album",
SeoOgType.MusicSong => "music.song",
_ => "website",
};
}
+2 -1
View File
@@ -2,8 +2,9 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
@inject SeoOptions Seo
<PageTitle>The Collective - Deep DRFT</PageTitle> <SeoHead Model="@SeoModel.ForAbout(Seo)" />
@* ────────────────────────────────────────────────────────────────────────────── @* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay. THE LINER NOTES — a numbered three-movement editorial essay.
+3 -1
View File
@@ -1,7 +1,9 @@
@page "/cuts" @page "/cuts"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Cuts</PageTitle> <SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Cut, "/cuts")" />
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table. @* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@ Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
@@ -1,8 +1,9 @@
@page "/archive" @page "/archive"
@using DeepDrftModels.Enums @using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Archive</PageTitle> <SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
<div> <div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container"> <MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
+8 -2
View File
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inherits CutDetailBase @inherits CutDetailBase
@inject SeoOptions Seo
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading) @if (ViewModel.IsLoading)
{ {
@@ -17,6 +16,9 @@
} }
else if (ViewModel.NotFound || ViewModel.Release is null) else if (ViewModel.NotFound || ViewModel.Release is null)
{ {
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container"> <div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead"> <div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText> <MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
@@ -37,6 +39,10 @@ else
var hasYear = release.ReleaseDate is not null; var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null; var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
@* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes
render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper @* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@ ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
+2 -1
View File
@@ -1,8 +1,9 @@
@page "/" @page "/"
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inject SeoOptions Seo
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle> <SeoHead Model="@SeoModel.ForHome(Seo)" />
@* Hero - split 50/50 *@ @* Hero - split 50/50 *@
<section class="hero"> <section class="hero">
+9 -2
View File
@@ -2,8 +2,7 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase @inherits ReleaseDetailBase
@inject SeoOptions Seo
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading) @if (ViewModel.IsLoading)
{ {
@@ -16,6 +15,9 @@
} }
else if (ViewModel.NotFound || ViewModel.Release is null) else if (ViewModel.NotFound || ViewModel.Release is null)
{ {
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container"> <div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead"> <div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText> <MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
else else
{ {
var release = ViewModel.Release; var release = ViewModel.Release;
var mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null;
@* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical
tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned @* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
+3 -1
View File
@@ -1,8 +1,10 @@
@page "/mixes" @page "/mixes"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase @inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Mixes</PageTitle> <SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
<ReleaseGallery Releases="@Releases" <ReleaseGallery Releases="@Releases"
Loading="@Loading" Loading="@Loading"
@@ -1,4 +1,9 @@
@page "/404" @page "/404"
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
@* The 404 must not be indexed (AC8): noindex,follow — no canonical, no JSON-LD. *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<MudText Typo="Typo.h3"> <MudText Typo="Typo.h3">
Not Found Not Found
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services @using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase @inherits ReleaseDetailBase
@inject SeoOptions Seo
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@if (ViewModel.IsLoading) @if (ViewModel.IsLoading)
{ {
@@ -20,6 +19,9 @@
} }
else if (ViewModel.NotFound || ViewModel.Release is null) else if (ViewModel.NotFound || ViewModel.Release is null)
{ {
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container"> <div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead"> <div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText> <MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
@@ -40,6 +42,10 @@ else
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder. // Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath; var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
@* SEO head — fed from the same bridged release, so prerender and WASM render identical tags (AC6).
MusicAlbum/LiveAlbum (a session is a live release, §3.4/OQ6). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release)" />
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does @* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
@@ -1,8 +1,10 @@
@page "/sessions" @page "/sessions"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls @using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase @inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Sessions</PageTitle> <SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
<ReleaseGallery Releases="@Releases" <ReleaseGallery Releases="@Releases"
Loading="@Loading" Loading="@Loading"
+10
View File
@@ -45,6 +45,16 @@ public static class Startup
services.AddScoped<IAnonIdProvider, AnonIdProvider>(); services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>(); services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
services.AddScoped<ShareTracker>(); services.AddScoped<ShareTracker>();
// Phase 22 SEO defaults — non-secret brand constants (canonical origin, site name, default share
// image, social links). Singleton: stateless config, identical in the server-prerender and WASM
// passes (this method runs in both), which is what makes SeoHead's double-render output identical.
services.AddSingleton(new SeoOptions());
// Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server
// seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same
// default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled).
services.AddScoped<SeoEnvironment>();
} }
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress) public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
+6
View File
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client @using DeepDrftPublic.Client
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Services @using DeepDrftPublic.Services
@using DeepDrftShared.Client.Components @using DeepDrftShared.Client.Components
<!DOCTYPE html> <!DOCTYPE html>
@@ -34,11 +35,16 @@
@code { @code {
[Inject] public required DarkModeService DarkModeService { get; set; } [Inject] public required DarkModeService DarkModeService { get; set; }
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
protected override void OnInitialized() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
DarkModeService.CheckDarkMode(); DarkModeService.CheckDarkMode();
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
// so both render passes resolve the same default robots (Production → index, else noindex).
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
} }
} }
+423
View File
@@ -0,0 +1,423 @@
using System.Text.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
/// nodes + <see cref="SeoUrls"/>). These are pure functions over the DTOs a page already holds — the
/// medium→schema mapping (AC3), graceful partial data (AC4), JSON-LD validity (AC5), and canonical
/// correctness (AC7) are all testable here without rendering. The JSON-LD is parsed back to a document so
/// each assertion checks real structure, not a substring.
/// </summary>
[TestFixture]
public class SeoModelTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
SiteName = "Deep DRFT",
TitleSuffix = "Deep DRFT",
DefaultDescription = "default description",
DefaultImageUrl = "/img/og-default.png",
Genre = "Electronic",
SameAs = ["https://instagram.com/deepdrft.music"],
};
private static ReleaseDto Release(
ReleaseMedium medium,
string? image = "cover.jpg",
string? description = "desc",
string title = "Test Release",
string artist = "Aphex Twin") => new()
{
EntryKey = "abc-key",
Title = title,
Artist = artist,
Genre = "House",
Description = description,
ImagePath = image,
ReleaseDate = new DateOnly(2026, 3, 14),
Medium = medium,
};
private static JsonElement Parse(string? json)
{
Assert.That(json, Is.Not.Null.And.Not.Empty, "expected a JSON-LD body");
return JsonDocument.Parse(json!).RootElement;
}
// --- AC3: per-medium schema -------------------------------------------------
[Test]
public void ForRelease_Cut_IsMusicAlbum_StudioAlbum_WithOrderedTrackList()
{
var tracks = new List<TrackDto>
{
new() { TrackName = "Second", TrackNumber = 2, DurationSeconds = 60 },
new() { TrackName = "First", TrackNumber = 1, DurationSeconds = 30 },
};
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/StudioAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
var trackArray = node.GetProperty("track");
Assert.That(trackArray.GetArrayLength(), Is.EqualTo(2));
// Ordered by TrackNumber, not input order.
Assert.That(trackArray[0].GetProperty("name").GetString(), Is.EqualTo("First"));
Assert.That(trackArray[1].GetProperty("name").GetString(), Is.EqualTo("Second"));
Assert.That(trackArray[0].GetProperty("duration").GetString(), Is.EqualTo("PT30S"));
});
}
[Test]
public void ForRelease_Session_IsMusicAlbum_LiveAlbum()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Session));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/LiveAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
});
}
[Test]
public void ForRelease_Mix_IsMusicRecording_WithIsoDuration()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3723 } }; // 1h 2m 3s
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"));
Assert.That(node.GetProperty("duration").GetString(), Is.EqualTo("PT1H2M3S"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicSong));
Assert.That(model.DurationSeconds, Is.EqualTo(3723));
// A mix is one recording, not an album — it carries no track list.
Assert.That(node.TryGetProperty("track", out _), Is.False);
});
}
[Test]
public void ForRelease_AllNodes_DeclareSchemaOrgContext()
{
foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
var node = Parse(SeoModel.ForRelease(Options, Release(medium)).JsonLd);
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
$"{medium} node must declare the schema.org context");
}
}
// --- AC4: graceful partial data ---------------------------------------------
[Test]
public void ForRelease_NoDescription_FallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, description: null));
Assert.That(model.Description, Is.EqualTo(Options.DefaultDescription));
}
[Test]
public void ForRelease_NoCover_OmitsImagePath_SoHeadFallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, image: null));
// The model carries no relative ImagePath; the JSON-LD image is absolutised to the default.
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(model.ImagePath, Is.Null);
Assert.That(node.GetProperty("image").GetString(), Is.EqualTo("https://deepdrft.com/img/og-default.png"));
});
}
[Test]
public void ForRelease_NoGenre_OmitsGenreProperty_NoEmptyValue()
{
var release = Release(ReleaseMedium.Cut);
release.Genre = null;
var node = Parse(SeoModel.ForRelease(Options, release).JsonLd);
Assert.That(node.TryGetProperty("genre", out _), Is.False, "a null genre must be omitted, not emitted empty");
}
[Test]
public void ForRelease_Mix_NoTrack_OmitsDuration()
{
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks: null).JsonLd);
Assert.That(node.TryGetProperty("duration", out _), Is.False, "a mix with no track must omit duration");
}
// --- AC7: canonical correctness ---------------------------------------------
[TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/abc-key")]
[TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/abc-key")]
[TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/abc-key")]
public void ForRelease_CanonicalPath_IsDedicatedRoute_AndJsonLdUrlAgrees(ReleaseMedium medium, string expectedAbsolute)
{
var model = SeoModel.ForRelease(Options, Release(medium));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(SeoUrls.Absolute(Options, model.CanonicalPath!), Is.EqualTo(expectedAbsolute));
// The JSON-LD url must be the same absolute canonical (AC7: canonical == og:url == node url).
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo(expectedAbsolute));
});
}
// --- Home / About / Browse / NotFound ---------------------------------------
[Test]
public void ForHome_IsMusicGroup_WithSameAs()
{
var model = SeoModel.ForHome(Options);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Deep DRFT"));
Assert.That(node.GetProperty("sameAs")[0].GetString(), Is.EqualTo("https://instagram.com/deepdrft.music"));
Assert.That(model.CanonicalPath, Is.EqualTo("/"));
});
}
[Test]
public void ForAbout_IsMusicGroup()
{
var node = Parse(SeoModel.ForAbout(Options).JsonLd);
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
}
[TestCase(ReleaseMedium.Cut, "/cuts")]
[TestCase(ReleaseMedium.Session, "/sessions")]
[TestCase(ReleaseMedium.Mix, "/mixes")]
public void ForBrowse_IsCollectionPage_WithAbsoluteUrl(ReleaseMedium medium, string path)
{
var model = SeoModel.ForBrowse(Options, medium, path);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"));
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo($"https://deepdrft.com{path}"));
Assert.That(model.CanonicalPath, Is.EqualTo(path));
});
}
[Test]
public void ForBrowse_NullMedium_IsArchive()
{
var model = SeoModel.ForBrowse(Options, null, "/archive");
Assert.That(model.Title, Is.EqualTo("Archive"));
}
[Test]
public void ForNotFound_IsNoindex_NoCanonical_NoJsonLd()
{
var model = SeoModel.ForNotFound(Options);
Assert.Multiple(() =>
{
Assert.That(model.Robots, Is.EqualTo("noindex,follow"));
Assert.That(model.CanonicalPath, Is.Null);
Assert.That(model.JsonLd, Is.Null);
});
}
// --- byArtist consistency: per-release artist, matching music:musician -------
[TestCase(ReleaseMedium.Cut)]
[TestCase(ReleaseMedium.Session)]
[TestCase(ReleaseMedium.Mix)]
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
{
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
});
}
[Test]
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
{
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
Is.EqualTo("Aphex Twin"));
}
// --- AC5 regression: no stray CLR `Type` property emitted alongside `@type` ---
// System.Text.Json previously emitted both `@type` (from the base [JsonPropertyName]) and `Type`
// (the raw CLR override name) on concrete derived nodes, failing the schema.org validator.
// The fix: repeat [JsonPropertyName("@type")] directly on each concrete override.
[Test]
public void MusicAlbumNode_SerializesAtType_OnlyOnce_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"),
"MusicAlbumNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicAlbumNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_TopLevel_SerializesAtType_NoBareTypeProperty()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3600 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"MusicRecordingNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicRecordingNode_NestedTrack_SerializesAtType_NoBareTypeProperty()
{
// The nested track[] MusicRecordingNode is a different code path from the top-level mix node.
var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
var albumNode = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
var trackNode = albumNode.GetProperty("track")[0];
Assert.Multiple(() =>
{
Assert.That(trackNode.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
"nested MusicRecordingNode must emit @type");
Assert.That(trackNode.TryGetProperty("Type", out _), Is.False,
"nested MusicRecordingNode must NOT emit a bare Type property");
});
}
[Test]
public void MusicGroupNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForHome(Options).JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"MusicGroupNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"MusicGroupNode must NOT emit a bare Type property");
});
}
[Test]
public void CollectionPageNode_SerializesAtType_NoBareTypeProperty()
{
var node = Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"),
"CollectionPageNode must emit @type");
Assert.That(node.TryGetProperty("Type", out _), Is.False,
"CollectionPageNode must NOT emit a bare Type property");
});
}
[Test]
public void ArtistRef_NestedByArtist_SerializesAtType_NoBareTypeProperty()
{
// ArtistRef (byArtist) was already clean — this asserts it stays clean after the fix.
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd);
var byArtist = node.GetProperty("byArtist");
Assert.Multiple(() =>
{
Assert.That(byArtist.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
"ArtistRef must emit @type");
Assert.That(byArtist.TryGetProperty("Type", out _), Is.False,
"ArtistRef must NOT emit a bare Type property");
});
}
[Test]
public void AllNodes_ContextIsPresent_AndSchemaOrg()
{
// Belt-and-suspenders: @context must not regress alongside the @type fix.
var nodes = new[]
{
Parse(SeoModel.ForHome(Options).JsonLd),
Parse(SeoModel.ForAbout(Options).JsonLd),
Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Session)).JsonLd),
Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix)).JsonLd),
};
foreach (var node in nodes)
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
"@context must remain present after the @type fix");
}
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
[Test]
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
{
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
// The escaped body still parses as JSON and round-trips the original value.
var node = Parse(body);
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
});
}
[Test]
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
{
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
});
}
}
+68
View File
@@ -0,0 +1,68 @@
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="SeoUrls"/> — the absolute-URL composition shared by the SeoModel factories
/// and SeoHead (Phase 22). Origin always comes from config (never a browser API), so these pin the
/// slash-join, the cover-vs-default fallback (C6/AC4), and the ISO-8601 duration edge cases.
/// </summary>
[TestFixture]
public class SeoUrlsTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
DefaultImageUrl = "/img/og-default.png",
};
[TestCase("/cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("/", "https://deepdrft.com/")]
[TestCase("", "https://deepdrft.com")]
public void Absolute_JoinsOriginAndPath_WithoutDoublingOrDroppingSlash(string path, string expected)
{
Assert.That(SeoUrls.Absolute(Options, path), Is.EqualTo(expected));
}
[Test]
public void Absolute_TrimsTrailingSlashOnBaseUrl()
{
var withSlash = Options with { BaseUrl = "https://deepdrft.com/" };
Assert.That(SeoUrls.Absolute(withSlash, "/cuts"), Is.EqualTo("https://deepdrft.com/cuts"));
}
[Test]
public void CoverOrDefault_WithCover_BuildsEscapedImageRoute()
{
Assert.That(SeoUrls.CoverOrDefault(Options, "my cover.jpg"),
Is.EqualTo("https://deepdrft.com/api/image/my%20cover.jpg"));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void CoverOrDefault_WithoutCover_FallsBackToDefaultImage(string? image)
{
Assert.That(SeoUrls.CoverOrDefault(Options, image),
Is.EqualTo("https://deepdrft.com/img/og-default.png"));
}
[TestCase(30.0, "PT30S")]
[TestCase(90.0, "PT1M30S")]
[TestCase(3723.0, "PT1H2M3S")]
public void IsoDuration_PositiveSeconds_RendersIso8601(double seconds, string expected)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.EqualTo(expected));
}
[TestCase(null)]
[TestCase(0.0)]
[TestCase(-5.0)]
[TestCase(double.NaN)]
[TestCase(double.PositiveInfinity)]
public void IsoDuration_NonPositiveOrNonFinite_ReturnsNull(double? seconds)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.Null);
}
}
+209
View File
@@ -443,8 +443,217 @@ not the same work; this phase does not satisfy or depend on that one.
--- ---
## Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)
The concrete realization of the long-deferred **"Non-WAV formats"** intent (`CONTEXT.md §5`). Daniel's
direction (2026-06-23): **two delivery formats per track — the existing lossless WAV path, and a new
low-data Ogg Opus (fullband, 320 kbps) path — so the listener gets a choice, with Opus the
bandwidth-friendly default-candidate.** Lossless streaming becomes *optional*, not the only path. The
bespoke Web Audio decode→schedule graph is **retained by deliberate choice** — Opus feeds the same
`IFormatDecoder` seam, not an HTML `<media>` element or MSE (the decision shared with Phase 21 OQ5).
**Sequenced BEFORE Phase 21** — windowing must work across both formats. Surfaces: ingest/preprocessing
in `DeepDrftContent` (`AudioProcessor`/router/`WaveformProfileService`) + `DeepDrftAPI`
(`UnifiedTrackService.UploadAsync`, replace-audio); delivery/decode in `DeepDrftAPI` (stream endpoint +
`Range`) + `DeepDrftPublic` proxy + `DeepDrftPublic.Client` player stack + `DeepDrftPublic/Interop/audio`
TS decoders. Full design, the three directions with SOLID/road-not-taken rationale, the storage and
delivery options, the Opus decoder + seek math, acceptance criteria, open questions, and wave
decomposition: `product-notes/phase-18-opus-low-data-streaming.md`.
**Much further along than the backlog line implies (verified 2026-06-23).** The multi-format *substrate*
already exists on both sides: the producer-side `AudioProcessorRouter` routes `.wav`/`.mp3`/`.flac` and
`TrackContentService.AddTrackAsync` is format-agnostic (it **stores originals**, no transcode); the
decoder-side `AudioPlayer.createFormatDecoder` is a **wired** strategy registry dispatching on
`Content-Type` (WAV/MP3/FLAC decoders all present — correcting the Phase 21 spec's stale
"implemented-not-wired" note). **The actual gap is Daniel's specific ask:** (1) a **transcode-at-ingest**
step that *derives* an Opus 320 artifact per track (nothing derives Opus today), and (2) a **per-format
delivery selection** so one track serves as either WAV or Opus on request.
**Open questions RESOLVED (Daniel, 2026-06-23).** OQ1 selection UX → **global, via a new public-site
Settings menu** (not a bare app-bar control); OQ2 default → **Opus by default, capability-gated** (defer
network-awareness); OQ3 remembered → **persisted via the dark-mode seam** (cookie → prerender-read →
`PersistentComponentState` → client cookie service); OQ4 → **always-on Opus + Backfill-Opus**; OQ5 →
**Ogg Opus**; OQ6 transcode model → **background job after the file is available, with a visible
Post-Processing phase on the CMS upload meter.** OQ7 (seek-index granularity) → **0.5 s (half-second)
buckets** (~115 KB index for a 1-hour mix).
**Architectural spine — a derived artifact set + a delivery param + one new decoder + a precomputed
accurate seek index; leaf implementations only, zero changes to existing format code (the strong OCP
signal).** Transcode is a new processor sibling in `DeepDrftContent`, invoked post-store alongside
`WaveformProfileService` **as a background job** (a 1 GB WAV transcode must not block the upload; the source
is stored and the track plays lossless *first*, then Opus is derived) — mirroring the landed waveform-datum
pattern (derive at ingest, regenerate via a CMS bulk action + ApiKey endpoint). The Opus bytes are a
**derived artifact** stored like the high-res waveform datum (recommend a dedicated `track-opus` vault, the
`track-waveforms` precedent; final call staff-engineer's). Delivery adds a **`?format=opus|lossless` param**
(mirroring the existing `offset` param threading through `TrackProxyController`) resolved server-side to the
right artifact + content-type, with a **lossless fallback** when no Opus artifact exists (additive, never
404/silence). The player gains one `OpusFormatDecoder` (`IFormatDecoder`): Ogg-page-aligned segmenting
(`OggS` scan — the FLAC frame-sync analogue) and `OpusHead`/`OpusTags` setup-bytes carry (the FLAC
`streamInfoBytes` analogue). **Browser constraint flagged:** Ogg-Opus `decodeAudioData` is Safari-18.4+ only
(Chrome/FF long-standing), so the Opus default is **capability-gated** — fall back to the universal lossless
path on browsers that can't decode it.
**VBR-safe ACCURATE seeking (Daniel, 2026-06-23 — supersedes the earlier "approximate" hand-wave).** Raw
byte-offset seek and rough page interpolation are inadequate for VBR Opus — there is no linear time↔byte
relationship. The fix is an **accurate transfer function built at transcode time** (the one moment the
whole encoded stream is walked): a precomputed **seek index** mapping Ogg-page `granulepos` (48 kHz sample
counts → time) → exact byte offset (**0.5 s buckets** snapped to page starts — OQ7; ~7,200 entries ×
16 bytes ≈ ~115 KB for a 1-hour mix). The decode **setup header** (`OpusHead`/`OpusTags`, needed to decode any mid-stream slice) is made
available too. Recommended concrete design: **one sidecar artifact per track = `[setup header][seek
index]`, built at transcode, stored beside the Opus bytes, fetched once on track load**, parsed into
`OpusSeekData`. Client seek flow: `calculateByteOffset(t)` binary-searches the index for the exact page
offset → `Range: bytes=X-` fetch (landed Phase 4 primitive, unchanged) → prepend the cached setup header →
decode → fine re-sync to `t` within the bucket. **The listener lands at the correct time, not
approximately** (AC9), **without** the full PCM in memory — so it composes with Phase 21 windowed refill,
which calls the **same** index resolver. The earlier "approximate page-interpolation" language is rejected.
**Constraints/invariants:** keep the bespoke graph (no MSE); preprocessing is **additive** (WAV path
untouched, byte-for-byte; a track with no Opus artifact still plays losslessly); reuse the landed
`Range`/offset seek path; no format branches leak outside the new decoder + one selection arm + the
transcode/delivery seam; transcode failure must not block ingest; format selection is a delivery-time
decision resolving one `EntryKey` to one of two artifacts (one source, two views — **not** a second
`TrackEntity` row, which would fracture share/queue/play-count/release identity).
Sequenced as six waves. `18.1 → 18.2 → {18.3, 18.4} → 18.5`, with `18.6` (Settings menu) able to run in
parallel (it needs only 18.3's format mechanism before its toggle is live). **18.1 (ingest transcode +
seek-index + setup-header derived artifacts) is the cold-start prerequisite** — nothing downstream has
bytes to serve, decode, or seek against until those artifacts exist.
- **18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing).** New
`OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from
`UnifiedTrackService.UploadAsync` alongside `WaveformProfileService` **as a background job** (OQ6);
produces Ogg Opus fullband 320; **walks the encoded stream once to build the granule→byte seek index and
extract the `OpusHead`/`OpusTags` setup header**; stores the Opus bytes **and** the combined seek/setup
**sidecar** as derived artifacts (recommend a `track-opus` vault). Failure-tolerant. **Independent of the
delivery/decoder waves — can begin immediately.**
- **18.2 — Storage + lookup contract.** The derived-artifact key/vault convention (Opus bytes + sidecar) +
server-side "given `EntryKey` + format, return the right `AudioBinary` + content-type (+ the sidecar),"
including the lossless fallback. **Depends on 18.1.**
- **18.3 — Delivery: `?format=opus|lossless` param + sidecar serving + proxy threading.** On the
`DeepDrftAPI` stream endpoint (resolves via 18.2), forwarded through `TrackProxyController` (mirror
`offset`), `Range` serving the chosen artifact; **plus serving the seek/setup sidecar**; player sends the
format param via `TrackMediaClient`. **Depends on 18.2; parallel-ok with 18.4.**
- **18.4 — `OpusFormatDecoder` + index-based seek resolver in the player stack.** New `IFormatDecoder`
(Ogg-page segmenting via `OggS` scan, `OpusHead`/`OpusTags` setup carry from the cached sidecar,
**`calculateByteOffset` that binary-searches the precomputed seek index** — NOT interpolation — with an
`OpusSeekData` accelerator holding the parsed index + setup bytes, and the one-time sidecar fetch+parse on
track load) + one arm in `createFormatDecoder` on `audio/ogg`/`audio/opus`; capability detection for the
lossless fallback. **Depends on 18.2; parallel-ok with 18.3.**
- **18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy).** "Backfill Opus" CMS
bulk action (third sibling to Generate-Profiles / Backfill-High-res), rebuilding Opus bytes + sidecar for
existing tracks; replace-audio Opus + sidecar regeneration; the AC1AC10 acceptance pass **including AC9
(an Opus seek lands at the correct time, not approximately)** and the Phase-21 handshake (Opus windowable
via the index resolver + sidecar setup header). **Depends on 18.118.4.**
- **18.6 — Public Settings menu + quality toggle (the listener selection UX).** New public-site
Settings-menu shell (app-bar trigger + MudBlazor menu + a settings-item abstraction + a
`PublicSiteSettings`/`ListenerSettings` object + the dark-mode-pattern persistence seam: `streamQuality`
cookie, a `DeepDrftPublic` prerender-read service, `PersistentComponentState` bridge, client cookie
service); the **quality toggle is its first occupant** (Low-data/Lossless, Opus default, capability-gated)
+ the CMS upload meter's **Post-Processing phase** (OQ6). Built design-for-adaptability so dark mode can
plug in later without restructuring (not migrated now). **Depends on 18.3** for the toggle; the menu shell
can be built ahead. *Splittable* (shell, then toggle) if Daniel wants the shell proven first.
**Dependency shape:** `18.1 → 18.2 → {18.3 ∥ 18.4} → 18.5`; `18.6 ∥` (needs 18.3 for the live toggle);
18.1 is the only cold-start wave. **Phase-level: 18 precedes Phase 21** (windowed refill consumes the Phase
18 seek-index resolver). **OQ1OQ7 RESOLVED (above); OQ7 (seek-index granularity) = 0.5 s buckets.** None
block 18.1.
--- ---
## Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)
Bound the **client memory** a playing track consumes to a small, configurable forward window —
**independent of total stream length** — so a 1 GB+ DJ MIX (Phase 9 `Mix` medium: a single long track)
plays without the whole decoded PCM accumulating in the browser. **Public listener site only**
(`DeepDrftPublic.Client` player stack + `DeepDrftPublic` TypeScript audio interop); no CMS, no API
endpoint, no schema change.
**Sequenced AFTER Phase 18 (Opus Low-Data Streaming) — Daniel, 2026-06-23.** Format support (the
derived Ogg Opus 320 low-data path, Phase 18) is a prerequisite that comes first; windowing must work
across **both** delivery formats. Phase 21's C5 invariant already anticipated this ("must not foreclose
MP3/FLAC"); **Opus is now the concrete VBR/paged driver** — windowing an Opus stream uses the decoder's
**accurate index-based** byte↔time mapping (`OpusFormatDecoder.calculateByteOffset`, a binary search in the
Phase 18 precomputed seek index — *not* the exact CBR-WAV `byteRate` math, and *not* approximate page
interpolation: VBR-safe and exact, per the Phase 18 seek-model resolution 2026-06-23). The windowed refill
controller calls the **same** index resolver an explicit seek does, and a window opening away from byte 0
still decodes via the Phase 18 sidecar setup header. Build the window machinery format-agnostically so it
inherits Opus for free.
The network path already streams in adaptive 1664 KB chunks. The accumulation is on the **decode
side**: `PlaybackScheduler` holds an `AudioBuffer[]` it **never evicts** ("Supports pause/resume/seek by
retaining all buffers" — its own doc comment). Decoded PCM is larger than the source (Web Audio is
32-bit float per sample/channel — a 16-bit stereo WAV roughly doubles once decoded), so a 1 GB WAV
becomes ~2 GB of retained float data. That is the OOM. The fix: hold only a sliding forward window plus a
small back-retain, discard already-played buffers, and refill on demand.
**Architectural spine — a sliding window keyed on playback position, built as a generalization of the
landed seek-beyond-buffer path.** The Phase 4 HTTP `Range: bytes=X-` → 206 primitive already does every
plumbing primitive the window needs (discard-buffers-keep-offset via `clearForSeek`/`setPlaybackOffset`;
fetch-from-offset via `TrackMediaClient`; decode-header-less-body via
`StreamDecoder.reinitializeForRangeContinuation`; time→byte via `IFormatDecoder.calculateByteOffset`),
just triggered manually and one-shot. The only genuinely new mechanisms are **partial eviction** on the
scheduler and **back-pressure** on the forward read loop (stop calling `ReadAsync` above a high-water
mark, resume below low-water). Recommended **Direction A** (sliding window on the existing single forward
stream); **Direction B** (discrete Range-fetched segments — the HLS/DASH/MSE-eviction analogue) held as
the documented fallback; **Direction C** (adopt MSE and let the browser manage the buffer) **rejected
(OQ5 = NO, Daniel 2026-06-23)** — the bespoke Web Audio graph is a deliberate long-term commitment, and
the compressed-delivery move that would have justified MSE is met instead by **Phase 18 (Opus) feeding
the same bespoke graph** through the `IFormatDecoder` seam. Direction A is therefore the permanent
destination, not a stopgap MSE would retire.
**Invariants that must hold (the §3.5 seam contract).** Reuse the Range path, don't fork it; playback-
start latency at parity; the `IFormatDecoder` abstraction untouched (windowing is format-agnostic, so
wiring MP3/FLAC later inherits it free); read-only playback (no new control); the single-instance JS
decoder stays single-writer (every refill routes through the existing cancellation/drain discipline). The
**Mix visualizer is provably unaffected** — it renders from the preprocessed per-track high-res datum
(Phase 10/12), never from live decoded PCM, so evicting played buffers cannot starve it. The 1 GB mix is
both the canonical case *and* the proof the eviction is safe.
**Interaction with deferred Phase 1 features (same seam):** windowing should land **before** preload
(1.3) — it makes preload of long tracks memory-safe by construction (a staged next-track decoder inherits
the bounded scheduler); it makes crossfade (1.4) between two long mixes affordable (the overlap doubles
the *window*, not the track); it adds a minor "don't evict the final window before the gapless boundary"
care point for 1.5. It **enlarges the error surface** (1.6): windowed refill issues mid-stream fetches
the listener didn't initiate, one of which can fail deep into a 1 GB mix — so the *cheap* half of 1.6
(clean refill-failure handling, no wedged player) is folded into this phase's acceptance criteria, not
left fully to 1.6.
Full design, the three directions with SOLID/road-not-taken rationale, use cases, acceptance criteria,
the open-question set, and the wave decomposition: `product-notes/phase-21-windowed-streaming-buffer.md`.
Sequenced as four waves. `21.1 → 21.2 → 21.3`, with `21.4` validating the whole. **21.1 is the cold-start
prerequisite and the load-bearing change** — independent of the open questions (window *sizes* are
parameters fed in later).
- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; load-bearing).** Drop already-played
buffers while keeping the position/index/time-anchor bookkeeping exact against a buffer array that no
longer begins at absolute time 0 (today `getCurrentPosition`/`playFromPosition`/the schedule loop all
assume `buffers[0]` is the track start). The hardest correctness work in the phase. No refill yet.
**Independent of the open questions — can begin immediately.**
- **21.2 — Back-pressure on the forward read loop.** Stop `ReadAsync` above the high-water mark, resume
below low-water; together with 21.1 this bounds *both* the played and unplayed regions (the AC1
guarantee). Routes resume/pause through the existing single-loop cancellation discipline. **Depends on
21.1.**
- **21.3 — Seek-back-past-window refill.** When a backward seek lands earlier than the retained tail,
refetch via the existing seek-beyond-buffer Range path pointed at the earlier offset; plus the minimal
clean refill-failure handling (the 1.6 adjacency). Mostly reuse of the landed seek path. **Depends on
21.1 + 21.2.**
- **21.4 — Validation against the 1 GB target (acceptance).** Memory profiling (bounded under 1 GB is the
headline), latency parity, edge-to-edge playback, the seek matrix, induced refill failure, visualizer-
running, rapid-seek concurrency. Largely measurement; breaks are tuning fixes in 21.1's anchor math or
21.2's water-marks. **Depends on 21.121.3.**
**Dependency shape:** `21.1 → 21.2 → 21.3 → 21.4`; 21.1 is the only cold-start wave. **Phase-level
prerequisite: Phase 18 (Opus) lands first** so windowing is built against both formats. **Open questions
for Daniel (spec §6):** window-size policy axis (time-based window + memory guard — recommended); seek-
back-past-window re-buffer acceptable (recommend yes, symmetric to forward); a hard total in-flight
memory cap as a guard rail (recommend yes); window everything vs. only long tracks (recommend everything
— one path, short tracks never hit a refill). **OQ5 (adopt MSE) — RESOLVED NO (Daniel 2026-06-23): the
bespoke graph stays by deliberate choice; recorded considered-and-declined, kept visible per file
convention.** None block 21.1.
---
## Working with this file ## Working with this file
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing. - **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
+3 -2
View File
@@ -181,6 +181,7 @@ if need_cred "authblocks"; then
read -rp " Email host (SMTP server or API host): " EMAIL_HOST read -rp " Email host (SMTP server or API host): " EMAIL_HOST
read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
echo echo
read -rp " Sender email address (From:, e.g. noreply@${DOMAIN_PUBLIC}): " EMAIL_FROM
# Admin account # Admin account
echo echo
@@ -201,10 +202,10 @@ if need_cred "authblocks"; then
read -rp " Support email address: " SUPPORT_EMAIL read -rp " Support email address: " SUPPORT_EMAIL
write_cred "authblocks" "$(cat <<JSON write_cred "authblocks" "$(cat <<JSON
{"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}} {"AuthBlocks":{"Jwt":{"Secret":"$(json_escape "${JWT_SECRET}")","Issuer":"$(json_escape "${JWT_ISSUER}")","Audience":"$(json_escape "${JWT_AUDIENCE}")"},"Email":{"Host":"$(json_escape "${EMAIL_HOST}")","Token":"$(json_escape "${EMAIL_TOKEN}")","From":"$(json_escape "${EMAIL_FROM}")"},"Admin":{"UserName":"$(json_escape "${ADMIN_USERNAME}")","Email":"$(json_escape "${ADMIN_EMAIL}")","Password":"$(json_escape "${ADMIN_PASSWORD}")"},"SupportEmail":"$(json_escape "${SUPPORT_EMAIL}")"}}
JSON JSON
)" )"
unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN unset JWT_SECRET JWT_ISSUER JWT_AUDIENCE EMAIL_HOST EMAIL_TOKEN EMAIL_FROM
unset ADMIN_USERNAME ADMIN_EMAIL ADMIN_PASSWORD SUPPORT_EMAIL unset ADMIN_USERNAME ADMIN_EMAIL ADMIN_PASSWORD SUPPORT_EMAIL
else else
echo "[setup-step10-creds] authblocks.json already exists, skipping" echo "[setup-step10-creds] authblocks.json already exists, skipping"
@@ -0,0 +1,779 @@
# Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)
Product spec. Status: **design / framing — open questions RESOLVED (Daniel, 2026-06-23); implementation-ready.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
> **Resolution pass (Daniel, 2026-06-23).** OQ1OQ7 are resolved (see §6 — each marked RESOLVED, kept
> visible per file convention; OQ7 — seek-index granularity — set to **0.5 s buckets**). Two resolutions
> reshaped the spec materially: (a) the listener quality
> selection lives inside a **new public-site Settings menu surface** (not a bare app-bar control) — §4 +
> §4a; and (b) Daniel rejected the "approximate page-interpolation" seek hand-wave outright — **VBR-safe
> *accurate* seeking is now a first-class part of the architecture** (a precomputed seek-index artifact +
> a separately-available setup header). §3.4 is rewritten and a dedicated seek-model section (§3.4a)
> added. The Phase 21 cross-reference is updated to read "accurate index-based mapping," not
> "approximate."
This phase is the concrete realization of the long-deferred **"Non-WAV formats"** intent
(`CONTEXT.md §5`, the "1.2" the streaming-feature items reference). It supersedes the abstract "a
processor per format + a decoder strategy" framing with a specific, Daniel-directed product: **two
delivery formats per track — the existing lossless WAV path and a new low-data Ogg Opus path — so the
listener gets a choice, with Opus the bandwidth-friendly default-candidate.**
Surfaces (named precisely):
- **Ingest / preprocessing:** `DeepDrftContent` (`AudioProcessor` / `AudioProcessorRouter` /
`TrackContentService` / `WaveformProfileService`) + `DeepDrftAPI` (upload/persist —
`UnifiedTrackService.UploadAsync`, replace-audio) + `DeepDrftManager` (CMS upload form — the
**Post-Processing phase** on the existing upload progress meter, §3.1a).
- **Delivery / decode:** `DeepDrftAPI` (the track stream endpoint + `Range` handler + the new
**seek-index** and **setup-header** sidecar endpoints, §3.4a) + `DeepDrftPublic` proxy
(`TrackProxyController`) + `DeepDrftPublic.Client` player stack (`StreamingAudioPlayerService`,
`TrackMediaClient`) + `DeepDrftPublic/Interop/audio` TS decoders (`AudioPlayer.createFormatDecoder`
registry, a new `OpusFormatDecoder`).
- **Listener settings (NEW surface):** `DeepDrftPublic.Client` — a public-site **Settings menu** (app-bar
menu/popover) hosting the quality toggle as its first occupant, with a dark-mode-pattern persistence
seam (cookie → settings object → `PersistentComponentState` → client cookie service). §4a. The
prerender-cookie read lives in `DeepDrftPublic` (alongside `DarkModeService`).
**Sequencing headline: Phase 18 comes BEFORE Phase 21 (Windowed Streaming Buffer).** Phase 21's
windowing must work across both formats — its C5 invariant already anticipated this ("must not
foreclose MP3/FLAC"); Opus is now the concrete VBR/containerized driver of that invariant. See §6 and
the Phase 21 cross-reference.
---
## 0. State of the world (what already exists — verified 2026-06-23)
This phase is **much further along than the "Non-WAV formats" backlog line implies**, on both sides.
Two prior efforts already built most of the multi-format substrate; what is *missing* is specifically
the **derived-Opus-artifact** idea, not generic format support.
**Producer side is already multi-format (router landed):**
- `AudioProcessorRouter.ProcessAudioFileAsync(filePath)` routes by extension — `.wav`
`AudioProcessor`, `.mp3``Mp3AudioProcessor`, `.flac``FlacAudioProcessor`
(`DeepDrftContent/CLAUDE.md`).
- `TrackContentService.AddTrackAsync(filePath, mimeType)` is **format-agnostic**: it selects the
processor, generates an entry GUID, and **stores the original bytes** with correct extension/MIME
in the `tracks` vault.
- So today the system can *ingest and store* WAV/MP3/FLAC. It **does not transcode** — it keeps the
original. There is no derived artifact and no second format per track.
**Decoder side is a wired strategy registry (not "implemented-not-wired" anymore):**
- `AudioPlayer.createFormatDecoder(contentType)` (`AudioPlayer.ts:117`) dispatches on `Content-Type`:
`audio/mpeg|audio/mp3``Mp3FormatDecoder`, `audio/flac|audio/x-flac``FlacFormatDecoder`,
default → `WavFormatDecoder`. All three decoders exist and implement `IFormatDecoder`.
- `IFormatDecoder` (`IFormatDecoder.ts`) is a clean per-format strategy: `tryParseHeader`,
`getAlignedSegmentSize`, `wrapSegment`, `calculateByteOffset`, plus a `FormatInfo` carrying
`byteRate`, `blockAlign`, `audioDataOffset`, and a `seekData` accelerator slot (already polymorphic:
`Mp3VbrSeekData | FlacSeekData`). **This is the seam an `OpusFormatDecoder` slots into.**
- **Correction to the Phase 21 spec's §2 C3 note** ("MP3/FLAC implemented, not yet wired"): the
registry *is* wired and dispatches on content-type today. Phase 21's invariant still holds; the
parenthetical is stale and is corrected by this phase's reconciliation.
**What this means for the gap.** Daniel's direction is **not** "add format support" — that substrate
exists. It is "**derive a second, low-data artifact (Opus fullband 320) at ingest and let the listener
pick which to stream.**" That is two genuinely new things: (1) a **transcode-at-ingest** step that
produces a derived artifact per track (the router stores originals; nothing derives Opus), and (2) a
**per-format delivery selection** so the same track can be served as either WAV or Opus on request.
---
## 1. Goal
**Dual-format delivery.** Every track is streamable in two formats:
- **Lossless** — the existing WAV path, unchanged. The archival / audiophile option.
- **Low-data** — a derived **Ogg Opus, fullband, 320 kbps** artifact. The bandwidth-friendly
default-candidate.
The listener chooses; Opus is the recommended default. The bespoke Web Audio decode→schedule graph is
**retained by deliberate choice** (Daniel) — Opus is fed through the same `IFormatDecoder` strategy
seam, not through an HTML `<media>` element or MSE.
**Why Opus fullband 320.** Opus is the modern, royalty-free, best-in-class lossy codec; "fullband"
(48 kHz, full 20 kHz audio bandwidth) at 320 kbps is transparent-to-most-listeners quality at roughly
**1/4 to 1/5 the bytes of 16-bit/44.1 stereo WAV** (~1411 kbps). For a 1 GB DJ MIX (Phase 9 `Mix`
medium), that is the difference between a ~1 GB transfer and a ~220 MB transfer — the headline
low-data win, and directly relevant to the Phase 21 long-stream case.
**Non-goals.** This phase does not retire WAV (it stays as the lossless option), does not change the
bespoke graph for MSE (explicitly rejected — see §2 / Phase 21 OQ5), and does not add new transport
mechanisms beyond the existing stream + `Range` primitive.
---
## 2. Constraints / invariants (the contract that must hold)
- **C1 — Keep the bespoke Web Audio graph. MSE is rejected (Daniel, deliberate).** The custom
decode→schedule graph is a long-term commitment, not a stopgap. Opus is fed through the existing
`IFormatDecoder``StreamDecoder``PlaybackScheduler` pipeline. (This is the same decision
recorded as **Phase 21 OQ5 = NO**; the two phases share it.)
- **C2 — Preprocessing is additive; the WAV path is untouched.** The Opus artifact is a **second
derived artifact per track**, not a replacement. The existing WAV in the `tracks` vault stays
byte-for-byte as it is today; the lossless stream path is unchanged. A track with no Opus artifact
(legacy rows, or a transcode that hasn't run yet) must still play losslessly — Opus is strictly
additive.
- **C3 — Reuse the landed `Range`/offset seek path; do not fork it.** Phase 4's
`Range: bytes=X-``206` primitive (client `TrackMediaClient``DeepDrftPublic` proxy →
`DeepDrftAPI`) is the substrate for Opus seek too. Opus seek math differs from WAV (VBR /
container-paged, see §3.4) but it is expressed through the **same** `IFormatDecoder.calculateByteOffset`
seam the MP3/FLAC decoders already use — no second seek mechanism.
- **C4 — Opus slots the `IFormatDecoder` registry; no format branches leak elsewhere.** The new
`OpusFormatDecoder` is selected by `AudioPlayer.createFormatDecoder` on `Content-Type:
audio/ogg`/`audio/opus`. The rest of the player stack stays format-agnostic. No `if (opus)` outside
the decoder and the one selection point.
- **C5 — Format selection is a delivery-time decision, resolved server-side from a listener
signal.** The same `TrackEntity` / `EntryKey` addresses both artifacts; the *format* is a parameter
on the stream request (query param or `Accept` negotiation — see §3.3), not a different track id and
not a different vault entry key. One track, two renderings (the standing "one source, multiple
views" preference applied to delivery).
- **C6 — Transcode failure must not block ingest.** If the Opus transcode fails or is slow, the
track still persists with its lossless artifact and is playable. Opus is generated best-effort and
can be (re)generated later — mirror the **waveform-datum** model (`WaveformProfileService`: compute
on upload, regenerate on demand via a CMS action), which is exactly the "derived artifact, generated
at ingest, regenerable" pattern this needs.
- **C7 — The vault model holds: derived artifact is a new entry, not a mutation.** The Opus bytes
live in the FileDatabase under the track's `EntryKey` — either in the existing `tracks` vault under
a derived key, or in a new sibling vault (see §3.2 options). Either way it is `AudioBinary` with the
`.opus`/`.ogg` extension and correct MIME, registered like any other vault resource.
---
## 3. Architectural shape
### 3.0 The mental model
A track has one **source artifact** (the uploaded WAV/MP3/FLAC, stored as-is today) and gains one
**derived low-data artifact** (Ogg Opus fullband 320, produced at ingest). The stream endpoint serves
*either*, selected per request. The player picks a decoder by the response `Content-Type` exactly as
it does today. Seeking uses the same `Range` primitive; the byte↔time math is the decoder's job.
```
INGEST (DeepDrftContent + DeepDrftAPI)
upload → AudioProcessorRouter (existing) → store SOURCE artifact in vault [unchanged]
→ TRANSCODE to Opus 320 → store DERIVED artifact [NEW]
→ WaveformProfileService (existing, unchanged)
DELIVERY (DeepDrftAPI → DeepDrftPublic proxy → DeepDrftPublic.Client → Interop/audio)
GET api/track/{id}?format=opus|lossless → serve the chosen artifact's bytes (+ Range) [NEW param]
player: createFormatDecoder(Content-Type) → OpusFormatDecoder | Wav | Mp3 | Flac [+1 decoder]
```
### 3.1 Where the transcode lives (relative to existing processing)
The transcode is a **new processor sibling** to the existing format processors, invoked **after** the
source is stored, in the same orchestration that already calls `WaveformProfileService`:
- It belongs in `DeepDrftContent` (the binary-content domain library) as e.g. an
`OpusTranscodeService` / `OpusProcessor`, **not** in a host and **not** in a controller (per the
`*.Services`-owns-domain-logic convention).
- It is invoked from `UnifiedTrackService.UploadAsync` (the same place `WaveformProfileService`
computes the high-res datum on every new track) and from the **replace-audio** path (which already
regenerates both waveform datums — Opus is the third derived thing to regenerate there).
- Like the waveform datum, it gets a **regenerate trigger**: a CMS per-track / bulk action and an
ApiKey-gated endpoint, so existing tracks can be backfilled. This mirrors the landed
"Generate All Profiles / Backfill High-res" bulk actions on `Releases.razor`**Backfill Opus**
is the natural third bulk action.
**The transcode engine itself is staff-engineer's call** (FFmpeg/libopus via a process invocation, a
managed binding, or a libopus P/Invoke). The spec fixes the *artifact* (Ogg Opus, fullband, 320 kbps)
and the *seam* (a derived artifact produced post-store, regenerable, failure-tolerant), not the tool.
Note a real operational constraint to flag for implementation: transcoding a 1 GB WAV is **CPU- and
time-expensive** and must not block the upload response — it wants the same off-the-hot-path treatment
the upload body staging already gets (`Upload:StagingPath`). This is the single biggest implementation
risk and is called out as such. The execution model is now **decided** (OQ6): **the source is stored and
the track is playable (lossless) first, then the Opus transcode runs as a background job** — see §3.1a
for the user-visible consequence on the upload UI.
### 3.1a Transcode execution model + the Post-Processing upload phase (RESOLVED — OQ6)
**Execution model (Daniel, 2026-06-23): background process *after* the file is available.** The upload
flow is now two distinct server-side stages with a hard ordering:
1. **Transfer + store + persist (existing, synchronous).** The WAV body streams in (the landed
`ProgressStreamContent` two-phase cancellation), the source is stored in the vault, the `TrackEntity`
is persisted, the waveform datums are computed. At the end of this stage **the track is fully playable
losslessly** — nothing about Opus gates a successful upload.
2. **Opus transcode (NEW, background, after stage 1 completes).** A queued/background job reads the
stored source, transcodes to Ogg Opus 320, builds the **seek index** and extracts the **setup header**
(§3.4a), and stores all three derived artifacts. Until it finishes, `?format=opus` for that track
falls back to lossless (C2). On failure the track stays lossless-only and is eligible for Backfill-Opus
(C6).
**The upload progress meter gains a visible Post-Processing phase.** The CMS upload forms
(`BatchUpload.razor` / `BatchEdit.razor`) already render a progress meter driven by `ProgressStreamContent`
(byte-transfer progress) and the two-phase cancellation (idle window during transfer, response-wait budget
after the body completes). The transcode is a **third visible phase** appended to that meter — after the
existing "uploading bytes" and "server is persisting" phases, a **Post-Processing** phase reflects the
background transcode's status (queued → transcoding → done / failed). This is an *addition* to the
existing meter, not a new UI.
- The admin sees: bytes transfer → server persists (track now exists + plays lossless) → **Post-Processing**
(Opus being derived). The form may complete/return the admin to the catalogue after stage 1 (the track
is live); the Post-Processing phase can continue to report against that track in the browse/release view
(the Opus waveform/profile columns on `Releases.razor` already poll-and-show per-track derived-artifact
status — Post-Processing status fits the same affordance family).
- **How status reaches the UI is staff-engineer's call** (poll the track's Opus-artifact presence, an SSE/
long-poll job channel, or a status field on the track read). The spec fixes that the phase is *visible*
and *non-blocking* — the admin is never made to wait on the transcode to consider the upload done.
- This composes with the **always-on** decision (OQ4): every upload triggers the background transcode;
there is no per-upload opt-out, so the Post-Processing phase always appears.
### 3.2 Where the Opus artifact is stored (two options)
**Option S1 — derived key in the existing `tracks` vault (recommended).** Store the Opus bytes under
a derived entry key alongside the source, e.g. `{entryKey}` for source and `{entryKey}.opus` (or a
parallel key convention) in the same `tracks` vault. *Pro:* no new vault type, co-located with the
source, simplest lookup. *Con:* mixes two artifacts per logical track in one vault's index.
**Option S2 — a new sibling vault (e.g. `track-opus`).** Mirror the `track-waveforms` precedent
(Phase 12 added a dedicated vault for the derived high-res datum). Opus bytes keyed by the same
`EntryKey` in a `track-opus` vault. *Pro:* clean separation of source vs. derived, matches the
established "derived artifacts get their own vault" pattern (`track-waveforms`), easy to enumerate /
backfill / purge independently. *Con:* one more vault to register.
**Recommendation: S2** — it is the pattern the codebase already chose for the *other* derived
per-track artifact (the high-res waveform datum), so it is the least surprising and keeps the source
`tracks` vault meaning exactly one thing. **Final call is staff-engineer's**; both are viable.
### 3.3 How a listener's format choice reaches the bytes
The stream endpoint gains a **format selector**. Two candidate mechanisms:
- **D-a — explicit query param** `GET api/track/{id}?format=opus|lossless` (recommended). Mirrors the
existing `offset` query param the proxy already forwards (`TrackProxyController`). Explicit,
cache-friendly (distinct URLs), trivial to thread through the proxy, and the player already knows
which it asked for. Server resolves the param → the right artifact → sets the right `Content-Type`,
which the player's existing `createFormatDecoder` then dispatches on. **No new decoder-selection
mechanism** — the response content-type does the work it already does.
- **D-b — HTTP content negotiation** (`Accept: audio/ogg` vs `audio/wav`). More "correct" REST, but
the proxy + WASM client wiring is fussier and caches are content-type-varied. Not worth it here.
**Recommended: D-a.** The selection *policy* (which format a given listener gets by default, and how
they switch) is a genuine **product call — see OQ1/OQ2**, deliberately not decided here. The
*mechanism* (a query param resolved server-side to an artifact + content-type) is settled.
Server-side fallback rule (C2): if `format=opus` is requested but no Opus artifact exists for that
track (not yet transcoded / backfilled), the endpoint **falls back to lossless** rather than 404ing —
Opus is additive, so its absence degrades to "you get the lossless one," never to "no audio."
### 3.4 The Opus decoder (the genuinely new decode work)
`OpusFormatDecoder implements IFormatDecoder` is the new code on the delivery side. **Ogg Opus is a
containerized, paged format — not raw-frame-sliceable** the way WAV PCM is. WAV's `wrapSegment` prepends a
44-byte PCM header to any PCM-aligned byte run; the current model assumes you can wrap an arbitrary aligned
raw-audio slice and hand it to `decodeAudioData`. Ogg Opus is page-structured (Ogg pages carrying Opus
packets, plus mandatory `OpusHead`/`OpusTags` **setup pages** at the very start). A mid-stream byte slice
is **not** independently decodable: it needs (1) the setup header prepended, and (2) to begin on an Ogg
**page boundary**. So:
- `OpusFormatDecoder.getAlignedSegmentSize` aligns to **Ogg page boundaries** — scan for the `OggS`
capture pattern (analogous to FLAC's frame-sync scan; the `IFormatDecoder` interface already passes
`rawData` to `getAlignedSegmentSize` for exactly this reason).
- `wrapSegment` / the continuation path **prepends the `OpusHead`/`OpusTags` setup bytes** to a mid-stream
page run before handing it to `decodeAudioData` (analogous to FLAC's `streamInfoBytes` carry in
`FlacSeekData`). The setup bytes come from the **setup-header mechanism** (§3.4a), not from re-reading
the stream start.
- A new `OpusSeekData` variant joins `Mp3VbrSeekData | FlacSeekData` in the `seekData` accelerator slot —
but for Opus it carries the **accurate seek index** (§3.4a), not a heuristic TOC.
**The `IFormatDecoder` abstraction already has the shape for both needs** — a format-specific `seekData`
accelerator and a setup-bytes carry — because FLAC needed the same kind of thing. The genuinely new part
is **where the seek index and setup header come from**, which §3.4a designs.
> **Seek is NOT approximate for Opus (Daniel, 2026-06-23 — supersedes the earlier hand-wave).** An earlier
> draft of this section proposed "granule-position/Ogg-page interpolation" — a best-effort approximate
> offset, the Opus analogue of MP3's Xing TOC. **That is rejected.** Daniel: *"Killing seeking for
> decoding is unacceptable… Raw bytes offset for seeking is no longer adequate due to VBR. We need an
> accurate transfer function for seek time → true file byte offset."* Opus seeking is **accurate**, backed
> by a precomputed index built at transcode time. See §3.4a.
**Browser decode-support constraint (real, must be designed around).** The bespoke graph decodes
segments via `AudioContext.decodeAudioData`. Ogg-Opus support in `decodeAudioData` is long-standing in
Chrome and Firefox but arrived in **Safari only at 18.4 (macOS 15.4 / iOS 18.4, March 2025)**; older
Safari decodes Opus only in a CAF container, not Ogg. iOS Safari is a primary music-listening surface,
so this is not a corner case. Implications: (1) the **lossless WAV path is the universal fallback** for
listeners whose browser can't decode Ogg Opus — which C2's additive design already provides for free;
(2) the format default is **capability-gated** (OQ2, RESOLVED) — don't hand Ogg Opus to a Safari that
can't decode it; detect support (a probe `decodeAudioData` on a tiny Opus blob, or a UA/version gate) and
fall back to lossless. This intersects Phase 1.7 (Safari compatibility) and is flagged there too.
([Browser support: caniuse / WebKit 18.4 release notes — see Sources.])
### 3.4a VBR-safe accurate seeking (the seek-index artifact + the setup-header mechanism)
This is the architectural core of the Opus delivery path, and it must compose with **Phase 21 windowed
refill** (where most of the stream is *not* in memory). The requirement, decomposed from Daniel's
direction:
1. Seeking must be preserved for Opus **without** having the full PCM decoded in memory.
2. Raw byte-offset seek is inadequate — a VBR Opus stream has **no linear time↔byte relationship**, so
`byteRate` math and even rough page interpolation are not accurate enough.
3. We need an **accurate transfer function: seek-time → true file byte offset.**
4. The decode setup header must be **available separately** (or cached before seeking past it), because a
mid-stream slice is undecodable without `OpusHead`/`OpusTags`.
**The key insight: the one moment we already walk the entire encoded stream is the transcode.** That is
precisely when an accurate index can be built for free. We never have to guess at delivery time — we read
the answer out of a precomputed artifact.
#### A. The seek-index artifact (the accurate transfer function)
At transcode time, after the Opus bytes are produced, **walk the encoded Ogg stream once and record, for
each Ogg page (or coarser bucket), the page's `granulepos` (a 48 kHz sample count → time) paired with its
**byte offset** in the file.** That granule→byte table *is* the exact transfer function. This is the Opus
analogue of FLAC's `SEEKTABLE` / MP3's Xing TOC — but **precomputed and exact**, not derived by
interpolation guessing. Ogg granule positions are authoritative sample counts, so the mapping is true, not
estimated.
- **What it contains.** An ordered list of `(timeSeconds | granulepos, byteOffset)` entries, plus the
total duration and total byte length (for clamping a seek to range). A binary little-endian array of
fixed-width records is the natural shape (e.g. a `uint64 granulepos` + `uint64 byteOffset` per entry);
the exact encoding is staff-engineer's, but it should be a **compact binary blob**, fetched once and
parsed into a typed array client-side.
- **Granularity vs. size — RESOLVED: 0.5 s (half-second) buckets (Daniel, 2026-06-23).** One entry per
Ogg page is the most precise but largest; an Ogg page is typically a few KB of audio (~tens of ms to a
few hundred ms), so a 1-hour mix could be tens of thousands of pages. The chosen bucket is **one index
entry per 0.5 seconds of audio** (snap each bucket boundary to the *nearest enclosing page start*, so
every indexed offset is still an exact page boundary). At 0.5 s granularity a 1-hour mix is
~7,200 entries × 16 bytes ≈ **~115 KB** — still a trivial one-time fetch, and 0.5 s seek resolution is
finer than required (the decoder re-syncs to the exact page within the bucket anyway — see the client
flow — so the in-bucket trim is *sub-half-second*, tighter than the earlier ~12 s recommendation).
**Per-page precision remains the fallback if 0.5 s buckets ever prove too coarse**, at a larger index.
The bucket size is now fixed; the *shape* (precomputed exact granule→byte, bucketed, snapped to page
starts) is unchanged.
- **Sidecar, not embedded (recommended).** Store the index as a **third derived artifact** alongside the
Opus bytes and the waveform datum — the same "derived artifacts get their own vault" pattern this phase
already uses (S2 / `track-opus`; the `track-waveforms` precedent). Keep it a separate vault resource
(e.g. `{entryKey}.seekidx` in a `track-opus` vault, or its own `track-opus-index` vault) rather than
embedding it in the Ogg stream. *Why sidecar:* it is fetched **once, up front** (small, cacheable),
independent of the audio byte stream; embedding it in the Ogg would force the client to read into the
stream to find it, defeating the "resolve the offset *before* the Range fetch" flow. *Road not taken —
derive the index lazily on first seek by scanning server-side:* rejected, because it re-walks the stream
at request time (the cost we avoid by computing at transcode) and gives nothing the precomputed sidecar
doesn't.
#### B. The setup-header mechanism (decodability of any mid-stream slice)
Any post-seek slice needs `OpusHead` + `OpusTags` prepended to decode. Two ways to make those bytes
available to the client:
- **B-a — Client-side caching of the leading setup pages on first read (recommended).** On first play, the
stream already begins at byte 0, so the client *already receives* the `OpusHead`/`OpusTags` pages as the
opening bytes. `OpusFormatDecoder.tryParseHeader` captures and **retains** those setup bytes (exactly as
`WavFormatDecoder` retains the parsed WAV header for `reinitializeForRangeContinuation` today, and FLAC
retains `streamInfoBytes`). Every subsequent post-seek continuation prepends the cached setup bytes. *No
new endpoint;* it reuses the header-retention discipline already in the codebase.
- **B-b — A dedicated setup-header sidecar endpoint** (`GET api/track/{id}/opus/header` → just the
`OpusHead`/`OpusTags` bytes, also derivable at transcode time and stored as a tiny artifact). *Pro:* a
seek can be served even if the listener seeks **before** the stream start has been read (e.g. a deep-link
that begins mid-track, or a Phase 21 window that opens away from byte 0). *Con:* one more endpoint +
artifact.
**Recommendation: B-a as the primary, B-b as a cheap insurance artifact.** B-a covers the overwhelming
common case (play-then-seek) with **zero new surface** — it is the WAV-header-retention pattern applied to
Opus. But Phase 21 windowing and deep-links can legitimately open a window that never read byte 0, so the
setup header should **also** be derivable on demand. Cheapest reconciliation: **extract the setup bytes at
transcode time and store them as a tiny sidecar artifact** (they are a few hundred bytes), and expose them
**either** as a small endpoint **or** simply prepend them to the seek-index sidecar's header region so the
single up-front index fetch *also* delivers the setup bytes. The latter folds B-b into the B-a fetch: **the
client's one up-front sidecar fetch returns both the seek index and the setup header**, so it always has
both before it ever issues a seek — and never needs byte 0 to have been read. **Recommended concrete
design: one sidecar per track = `[setup-header bytes][seek-index table]`, fetched once on track load,
parsed into `OpusSeekData`.** This is the cleanest: one new artifact, one new fetch, both needs met.
#### C. The client-side seek flow, end to end
With the sidecar (`OpusSeekData` = setup header + granule→byte index) fetched and parsed at track load:
1. **Resolve time → byte offset (accurate).** Listener seeks to `t` seconds. `OpusFormatDecoder.calculateByteOffset(t)`
does a binary search in the index for the largest entry with `time ≤ t`, returns its exact (page-start)
`byteOffset`. **No interpolation, no `byteRate` math.** (For WAV this method stays the exact CBR
calculation it is today — the seam is identical; only the Opus implementation reads an index.)
2. **Range fetch from the offset.** Issue `GET api/track/{id}?format=opus` with `Range: bytes={byteOffset}-`
— the **landed Phase 4 Range primitive, unchanged**. Server streams raw Opus bytes from that exact page
boundary (`206 Partial Content`).
3. **Prepend the cached setup header + decode.** The continuation path (the Opus analogue of
`StreamDecoder.reinitializeForRangeContinuation`) prepends the retained/sidecar `OpusHead`/`OpusTags`
bytes to the incoming page run, then feeds it to `decodeAudioData`. Because the index offset is an exact
page start, the stream is immediately Ogg-sync-aligned.
4. **Fine re-sync within the bucket.** The granule of the first decoded page tells the decoder the *exact*
time it landed at (≤ the bucket granularity ahead of `t`); the scheduler trims/positions to land
playback at `t` precisely. With 0.5 s buckets the trim is sub-half-second; with per-page granularity it
is near-zero. **Either way the listener lands at the correct time, not approximately** (AC9).
#### D. Composition with Phase 21 windowed refill
Phase 21's windowed refill controller resolves "I need bytes for playback position `P`" → a byte offset →
a Range fetch. **It calls the *same* `OpusFormatDecoder.calculateByteOffset` (the index-based resolver)
for Opus** that an explicit seek does — windowed refill is just a seek the listener didn't initiate. So the
seek index serves both: explicit seeks and the window's low-water refills both resolve through the index,
and both prepend the cached setup header. This is why §3.4a is in **Phase 18** (where the transcode that
builds the index lives), and Phase 21 *consumes* it. The Phase 21 spec's "approximate mapping" language for
Opus is now wrong and is corrected to **"accurate index-based mapping."**
#### E. Reuse vs. extend (the seam discipline)
- **Reused verbatim:** the Phase 4 `Range: bytes=X-` → 206 primitive (client → proxy → API); the
`IFormatDecoder.calculateByteOffset` seam; the header-retention/continuation discipline
(`reinitializeForRangeContinuation`'s Opus analogue); the derived-artifact-in-its-own-vault pattern
(`track-waveforms``track-opus`); the derive-at-transcode-regenerate-on-backfill lifecycle.
- **Extended (new):** the seek-index + setup-header **sidecar artifact** (built at transcode, stored
beside the Opus bytes); the one-time **sidecar fetch** on track load (parsed into `OpusSeekData`); the
index **binary-search resolver** inside `OpusFormatDecoder`. Three additions, all leaf-level — no change
to the Range mechanism, the proxy, or the format-agnostic player.
### 3.5 The three candidate directions (shape-level)
Per file convention the alternatives are recorded; the recommendation follows.
**Direction A — Derived Opus artifact at ingest + format param on delivery (recommended).** What §3.1
3.4a describe: transcode to Opus 320 post-store as a **background job** (OQ6), store as derived artifacts
(S2 vault) — the Opus bytes **plus the seek-index/setup-header sidecar** (§3.4a) — serve via a `?format=`
param resolved server-side to bytes + content-type, decode via a new `OpusFormatDecoder` in the existing
registry, **seek accurately via the precomputed index**. *Why recommended:* additive (C2), reuses every
existing seam (the processor orchestration, the waveform-datum derived-artifact pattern, the `Range` path,
the decoder registry, the header-retention discipline), and the only genuinely new code is one transcode
step (+ index build) + one decoder (+ index resolver). **Three** derived artifacts per track (Opus bytes,
seek sidecar, and the existing waveform datum), all regenerable.
**Direction B — On-the-fly transcode at delivery (no stored Opus artifact).** Transcode WAV→Opus per
request in the stream endpoint, streaming the Opus out as it encodes. *Why not (default):* moves
expensive CPU onto the **hot request path** (a 1 GB mix transcoded per play is untenable), breaks
`Range`/seek (you can't byte-offset into a stream you're encoding live), and defeats caching. It *is*
storage-cheaper (no second artifact on disk), so it is the fallback only if disk cost ever dominates —
but for a music site where the same tracks are played repeatedly, precompute-once wins decisively.
Rejected as the primary.
**Direction C — Replace WAV ingest with Opus-only (transcode and discard the lossless source).** Make
Opus *the* stored format; drop WAV. *Why not:* violates Daniel's explicit "lossless streaming
*optional* — two delivery formats, listener gets a choice." Lossless is a kept option, not a thing to
transcode away. Also irreversibly lossy at ingest (you can never recover the WAV). Rejected outright;
recorded only because "just store Opus" is the tempting simplification and the spec should say why not.
### 3.6 SOLID / road-not-taken rationale
- **OCP, via the existing seams.** The transcode is a new processor sibling (the router pattern is
already open for extension); the decoder is a new `IFormatDecoder` (the registry is already open for
extension); the artifact is a new derived vault resource (the `track-waveforms` precedent is exactly
this). Phase 18 adds **three new leaf implementations** and **zero changes to existing format code**
— the strongest possible OCP signal that the seams were designed right.
- **SRP, preserved.** Transcoding **and the seek-index build** are content-domain processor concerns
(`DeepDrftContent`); delivery selection is a thin endpoint concern (`DeepDrftAPI` resolves a param to an
artifact, and serves the sidecar); decode is the `OpusFormatDecoder`'s concern; byte↔time math stays
inside that decoder via `calculateByteOffset` (now reading the index, not interpolating). No
responsibility crosses a boundary it doesn't already own. The seek index is built **once, where the
stream is already walked** (transcode) — the natural home for an exact transfer function, never
recomputed at request time.
- **DIP / "one source, multiple views."** One `TrackEntity`/`EntryKey` is the single source; "lossless
WAV" and "low-data Opus" are two *views* (renderings) of it, diverging only at the delivery/decode
layer — the same discipline the dark-mode and track-browse surfaces follow.
- **Road not taken — a separate `TrackEntity` row (or a new track id) per format.** Tempting (one row
= one streamable file) but it fractures the track identity: shares, queues, play-counts (Phase 16),
release membership, and waveform data all key on one track, and doubling rows to carry a format
would force every one of those surfaces to dedupe. Format is a *delivery attribute of one track*,
not a *second track*. Rejected — keep one identity, two artifacts.
---
## 4. Format selection — the product surface (RESOLVED — global, via a Settings menu)
**Resolved (Daniel, 2026-06-23):** the listener's quality choice is **global** (one session/visitor-level
"streaming quality" preference, not per-track), Opus is the **default** (capability-gated), and the choice
is **remembered** following the dark-mode persistence pattern. Crucially: *"Global is perfect, but we need
a menu system for settings, don't just slap the quality control directly in the app bar."* So the toggle
does **not** sit bare in the app bar — it lives inside a proper **public-site Settings menu** (§4a), of
which it is the **first occupant**.
- **What the listener sees.** A Settings affordance in the public app bar opens a Settings menu; inside it,
a "Streaming quality" control with two options — **Low-data (Opus)** / **Lossless (WAV)** — defaulting to
Low-data. Picking lossless flips the global preference; the player sends the matching `?format=` on
subsequent stream requests (§3.3). On a browser that can't decode Ogg Opus, the control is shown but the
effective stream is lossless (capability gate, §3.4 / OQ2) — surface this honestly rather than letting
the listener pick a format that silently can't play.
- **Default before any choice:** Opus, capability-gated (OQ2 RESOLVED). A first-time visitor on a capable
browser streams Opus; on an incapable browser, lossless.
- **Persistence:** mirror the dark-mode seam exactly (OQ3 RESOLVED) — see §4a.
### 4a. The Settings menu surface (NEW — scoping + the dark-mode persistence pattern)
Daniel asked for a **menu system for settings**, not a control bolted onto the app bar, and noted the
existing **dark-mode toggle** is a natural future tenant of the same menu (design for adaptability — build
the menu so dark mode *could* move into it later, but **do not force that migration now**).
**Scoping recommendation: a small sub-track *within* Phase 18 (wave 18.6), not its own phase.** Reasoning:
- The menu's only **required** occupant right now is the quality toggle, which Phase 18 owns end to end —
splitting the shell into a separate phase would create a phase whose sole deliverable is an empty menu
waiting for Phase 18's toggle. That is ceremony, not separation of concerns.
- The menu is **small** — an app-bar trigger + a MudBlazor menu/popover + the persistence seam (which the
quality toggle needs *anyway*). It is not a platform; it is a container with one tenant.
- It carries a real **design-for-adaptability** obligation (it must be able to host dark mode and future
settings later), but that is a *shape* requirement on a small surface, not a phase's worth of work.
So: **build the Settings-menu shell as part of Phase 18 (wave 18.6), with the quality toggle as its first
occupant, designed so dark mode and future preferences can plug in without restructuring.** Flag for
Daniel: *if he wants the menu shell proven/landed independently before the quality toggle plugs in*, 18.6
can be split into "menu shell" then "quality toggle plugs in" — but they are small enough to land together.
This is **not** recommended as its own top-level phase. (If Daniel disagrees and wants a dedicated
"Public Settings Menu" phase that Phase 18's toggle then targets, that is a clean alternative — it just
front-loads a surface with no second tenant yet. Recommendation stands: sub-track.)
**The menu shell — design-for-adaptability requirements (so it survives new tenants):**
- A **settings-item abstraction**, not a hard-coded list. The menu renders a small set of settings entries;
adding dark mode later is adding an entry, not rewiring the menu. Each entry is a label + a control bound
to a persisted preference.
- A **single public-site settings object** carrying all listener preferences (today: streaming quality;
tomorrow: dark mode, and whatever follows). This is the `DarkModeSettings` analogue, generalized — call
it e.g. `PublicSiteSettings` / `ListenerSettings`. Dark mode's existing `DarkModeSettings` can fold into
it *later* without disturbing the menu.
**Persistence — mirror the dark-mode seam exactly (OQ3 RESOLVED).** The quality preference follows the
*identical* path dark mode already uses (root `CLAUDE.md` "Theming and dark mode"):
1. **Cookie** — a `streamQuality` cookie (365-day, like `darkMode`), the durable truth.
2. **Server prerender read** — a service in `DeepDrftPublic` (sibling to `DarkModeService`) reads the
cookie during prerender and seeds the settings object, avoiding a wrong-default flash on first paint
(the streaming-quality analogue of the "wrong theme flash" fix).
3. **`PersistentComponentState` bridge** — the seeded preference carries from server prerender into the
WASM render (the same bridge `DarkModeSettings` and `NowPlayingStats`/`StatsClient` already use), so the
client boots already knowing the quality without a re-read flash or a re-fetch.
4. **Client cookie service** — a runtime client-side service (JS-interop cookie write, like the dark-mode
toggle) persists the choice when the listener changes it in the menu.
**Why mirror rather than invent:** the dark-mode seam is the codebase's established, working pattern for "a
listener preference seeded at prerender, carried to WASM, persisted in a cookie." Reusing its shape means
the quality preference inherits the no-flash guarantee for free, and the eventual dark-mode-into-the-menu
migration is a *consolidation of two identical seams*, not a reconciliation of two different ones. (This is
the "one source, multiple views" / design-for-adaptability discipline applied to listener settings.)
---
## 5. Use cases
- **UC1 — Listener streams the low-data Opus of a long mix (the headline win).** A ~1 GB lossless mix
transfers as ~220 MB of Opus; playback through the bespoke graph is identical in feel, far cheaper
on bandwidth. (Compounds with Phase 21 windowing for the memory side.)
- **UC2 — Listener prefers lossless and switches to it.** The same track served as WAV via
`?format=lossless`; the bespoke graph decodes it exactly as today.
- **UC3 — Legacy / not-yet-transcoded track.** `?format=opus` requested, no Opus artifact yet →
server falls back to lossless (C2); the listener still hears the track. A later Backfill-Opus pass
produces the artifact.
- **UC4 — Admin backfills Opus for the existing catalogue.** A bulk "Backfill Opus" CMS action (the
third sibling to the existing Generate-Profiles / Backfill-High-res actions) transcodes every track
lacking an Opus artifact.
- **UC5 — Replace-audio regenerates Opus.** The existing replace-audio path (which already regenerates
both waveform datums and re-derives duration) also regenerates the Opus artifact from the new
source.
- **UC6 — Seek within an Opus stream (accurately).** Backward/forward seek resolves via the existing
`Range` path; the offset comes from the `OpusFormatDecoder`'s **precomputed seek index** (§3.4a) — an
exact granule→byte lookup, then fine re-sync to the requested time within the bucket. The listener lands
at the **correct** time, not approximately, and without the full PCM decoded in memory.
- **UC7 — Safari that can't decode Ogg Opus.** Capability-gated to the lossless path (§3.4), so the
listener still plays audio. (Ties to OQ2 + Phase 1.7.)
- **UC8 — Listener switches streaming quality in the Settings menu.** The listener opens the public
Settings menu, flips "Streaming quality" from Low-data to Lossless (or back); the choice persists
(cookie, dark-mode pattern) and applies to subsequent stream requests via `?format=`. On next visit the
preference is seeded at prerender (no flash, no re-pick). (§4 / §4a.)
- **UC9 — Deep-link / windowed start away from byte 0.** A listener opens a stream at a mid-track position
(deep link, or a Phase 21 window that opens past byte 0) without ever reading the stream start. The
decoder still has the `OpusHead`/`OpusTags` setup bytes because they arrived with the up-front sidecar
fetch (§3.4a B), so the mid-stream slice is decodable immediately. (Composition case for Phase 21.)
---
## 6. Open questions — RESOLVED (Daniel, 2026-06-23)
All seven open questions are resolved. Kept visible per file convention, each with the decision and
the section that now carries it. OQ7 (raised by the seek-model design) is a narrow tuning call, now set to
0.5 s buckets.
- **OQ1 — Selection UX — RESOLVED: global, via a Settings *menu* (not a bare app-bar control).** Daniel:
*"Global is perfect, but we need a menu system for settings, don't just slap the quality control directly
in the app bar."* So: one global quality preference, surfaced inside a new **public-site Settings menu**
(§4 / §4a), of which the quality toggle is the first occupant. The menu is scoped as a **Phase 18
sub-track (wave 18.6)**, designed so dark mode (its natural future tenant) can plug in later. `[RESOLVED
— §4 / §4a]`
- **OQ2 — Default policy — RESOLVED: Opus by default, capability-gated.** Opus is the default; on a browser
that cannot decode Ogg Opus (Safari < 18.4, §3.4), fall back to lossless rather than serving an
undecodable stream. Network-awareness (Opus on cellular / lossless on wifi) remains **deferred** as
gold-plating. `[RESOLVED — §3.4, §4]`
- **OQ3 — Remembered choice — RESOLVED: persisted, following the dark-mode pattern.** A `streamQuality`
cookie seeded at server prerender → settings object → `PersistentComponentState` bridge into WASM →
client cookie service for runtime writes. The full dark-mode seam mirrored (§4a). `[RESOLVED — §4a]`
- **OQ4 — Per-upload Opus control — RESOLVED: always-on + backfill.** Opus is generated for **every**
track, always (no per-upload opt-out). **Plus** a bulk **Backfill-Opus** CMS action processes the
existing catalogue. (The listener's lossless choice already covers "I want lossless," so a per-track
opt-out earns nothing.) `[RESOLVED — §3.1, UC4, wave 18.5]`
- **OQ5 — Container — RESOLVED: Ogg Opus.** `.opus` / `audio/ogg` (broadest `decodeAudioData` support). No
CAF/WebM fallback — the lossless path already covers browsers that can't decode Ogg Opus (§3.4).
`[RESOLVED — §3.4]`
- **OQ6 — Transcode execution model — RESOLVED: background job after the file is available; uploader shows
a Post-Processing phase.** The source is stored and the track is playable losslessly **first**; the Opus
transcode (+ seek-index build) runs as a **background job** afterward; the CMS upload progress meter
gains a visible **Post-Processing** phase reflecting the transcode status (§3.1a). A freshly uploaded
track is lossless-only until its Opus finishes — accepted, and now made visible rather than implicit.
`[RESOLVED — §3.1a]`
**New open question raised by the seek-model design (§3.4a) — RESOLVED:**
- **OQ7 — Seek-index granularity — RESOLVED: 0.5 s (half-second) buckets (Daniel, 2026-06-23).** The seek
index trades precision against size: per-Ogg-page (most precise, largest) vs. coarser time buckets snapped
to page starts. Daniel set the bucket at **0.5 s** (finer than the ~12 s the spec had recommended):
~7,200 entries × 16 bytes ≈ **~115 KB** for a 1-hour mix — still a trivial one-time fetch. The decoder
fine-re-syncs within the bucket so seek *accuracy* is unaffected; at 0.5 s the in-bucket trim is
sub-half-second, tighter than before. The shape (precomputed exact granule→byte, page-snapped) is
unchanged. `[RESOLVED — §3.4a A]`
---
## 7. Acceptance criteria
- **AC1 (headline) — Dual-format delivery works.** A track can be streamed as either lossless WAV or
Ogg Opus 320 from the same `EntryKey`, selected per request; both play correctly through the bespoke
Web Audio graph.
- **AC2 — Opus is the low-data win.** The Opus artifact of a representative track is materially smaller
than its lossless source (target ~1/41/5 the bytes); a long mix's Opus transfer is correspondingly
smaller.
- **AC3 — Additive, non-breaking (C2).** The existing lossless WAV path is byte-for-byte unchanged; a
track with no Opus artifact still plays losslessly; `?format=opus` on such a track falls back to
lossless (no 404, no silence).
- **AC4 — Transcode at ingest as a background job, regenerable (C6, OQ6).** A new upload stores the source
and is playable losslessly **immediately**; the Opus artifact (+ seek-index/setup-header sidecar) is
produced by a **background job** afterward; a transcode failure does not block the upload or break
playback; a Backfill-Opus action (re)generates artifacts for existing tracks; replace-audio regenerates
the Opus artifact and its sidecar from the new source.
- **AC4a — Post-Processing phase is visible on the upload meter (OQ6, §3.1a).** After the byte-transfer and
server-persist phases, the CMS upload progress UI shows a **Post-Processing** phase reflecting the
background transcode (queued → transcoding → done/failed). The admin is never blocked waiting on the
transcode; the track is live before Post-Processing finishes.
- **AC5 — Opus seek via the existing `Range` path (C3).** Forward and backward seek in an Opus stream
resolve through the landed `Range: bytes=X-` primitive, with the offset coming from
`OpusFormatDecoder.calculateByteOffset`; no new seek *transport* mechanism is introduced.
- **AC5a — Seek-index + setup-header sidecar exists and is fetched once (§3.4a).** Every track with an Opus
artifact has a sidecar carrying the setup header (`OpusHead`/`OpusTags`) and the granule→byte seek index;
the client fetches and parses it once on track load (into `OpusSeekData`) before issuing any seek.
- **AC9 (the seek-accuracy criterion) — an Opus seek lands at the *correct* time, not approximately.**
Seeking to time `t` in an Opus stream resolves via the precomputed index and lands playback at `t`
(within the fine-resync tolerance — sub-half-second at the chosen 0.5 s bucket granularity), **measurably
accurate**, not a `byteRate`/interpolation estimate. Verifiable: seek to a known marker (e.g. a downbeat
at a known timestamp) and confirm playback resumes there, not seconds off. This holds **without** the
full PCM decoded in memory (composes with Phase 21).
- **AC6 — No format branches leak (C4).** The only Opus-specific code is `OpusFormatDecoder`, its
`OpusSeekData` (carrying the index), the one `createFormatDecoder` selection arm, the transcode processor
(+ index build), the sidecar artifact + its serving, and the delivery param resolution. The
format-agnostic player/scheduler code is unchanged.
- **AC7 — Capability-safe default (OQ2).** A browser that cannot decode Ogg Opus is served (or falls
back to) the lossless path and plays audio; no listener gets silence because of codec support.
- **AC8 — Windowing-ready (the Phase 21 handshake).** The `OpusFormatDecoder`'s **index-based** byte↔time
resolver is the one Phase 21's windowed refill calls; Opus playback must be windowable by the same
machinery, and a windowed refill that opens away from byte 0 still decodes (setup header from the
sidecar — UC9). Verified jointly when Phase 21 lands on top (see §8 / Phase 21 cross-ref).
- **AC10 — The Settings menu hosts the quality toggle and persists the choice (§4 / §4a).** The public app
bar opens a Settings menu containing a "Streaming quality" control (Low-data / Lossless, defaulting to
Low-data, capability-gated); changing it persists via the `streamQuality` cookie and is seeded at
prerender on the next visit (no flash). The menu shell is built so a future dark-mode entry can plug in
without restructuring.
---
## 8. Wave decomposition
Dependency shape: `18.1 → 18.2 → {18.3, 18.4}`, with `18.5` (backfill + e2e) and `18.6` (settings menu)
on top. **18.1 (the transcode + seek-index/setup-header derived artifacts) is the cold-start
prerequisite** — until those artifacts exist, nothing downstream has bytes to serve, decode, or seek
against. 18.3 (delivery param) and 18.4 (the decoder + index resolver) are largely parallel once 18.2
(storage/lookup) settles, but both need artifacts to test against. **18.6 (the Settings menu) is the only
wave with no audio-pipeline dependency** — it can proceed in parallel with the whole stack; it merely needs
the `?format=` mechanism (18.3) wired before the toggle has anything to drive.
- **18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing).** New
`OpusTranscodeService`/processor in `DeepDrftContent`, invoked post-store from
`UnifiedTrackService.UploadAsync` alongside `WaveformProfileService`, **as a background job** (OQ6,
§3.1a); produces Ogg Opus fullband 320; **walks the encoded stream once to build the granule→byte seek
index and extract the `OpusHead`/`OpusTags` setup header** (§3.4a A/B); stores the Opus bytes **and** the
combined seek/setup **sidecar** as derived artifacts (S2 vault recommended). Failure-tolerant (C6).
**Independent of the delivery/decoder waves; can begin immediately.**
- **18.2 — Storage + lookup contract.** The derived-artifact key/vault convention (Opus bytes + sidecar)
and the server-side resolution "given `EntryKey` + format, return the right `AudioBinary` + content-type
(+ the sidecar on its own endpoint/path)," including the C2 fallback (no Opus → lossless). **Depends on
18.1** (artifacts must exist to resolve to).
- **18.3 — Delivery: format param + sidecar serving + proxy threading.** `?format=opus|lossless` on the
`DeepDrftAPI` track stream endpoint (resolves via 18.2), forwarded through the `DeepDrftPublic`
`TrackProxyController` (mirror the existing `offset` param threading), and the `Range` handler serving
the chosen artifact's bytes; **plus serving the seek/setup sidecar** (a `GET …/opus/seekdata`-style path,
proxied the same way). The player sends the format param via `TrackMediaClient`. **Depends on 18.2.**
Parallel-ok with 18.4.
- **18.4 — `OpusFormatDecoder` + the index-based seek resolver in the player stack.** New `IFormatDecoder`
implementation: Ogg-page-aligned `getAlignedSegmentSize` via `OggS` scan; `OpusHead`/`OpusTags` setup
carry in `wrapSegment`/the continuation path (sourced from the cached sidecar, §3.4a B); **`calculateByteOffset`
that binary-searches the precomputed seek index** (NOT interpolation), with an `OpusSeekData` accelerator
holding the parsed index + setup bytes; the **one-time sidecar fetch + parse** on track load. One new arm
in `AudioPlayer.createFormatDecoder` on `audio/ogg`/`audio/opus`. Capability detection for the lossless
fallback (§3.4, OQ2). **Depends on 18.2** (needs Opus bytes + sidecar). Parallel-ok with 18.3; they meet
at 18.5.
- **18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy).** The Backfill-Opus CMS
bulk action (third sibling to Generate-Profiles / Backfill-High-res), which (re)builds Opus bytes + the
sidecar for existing tracks; replace-audio Opus + sidecar regeneration; and the AC1AC10 acceptance pass
**including AC9 (an Opus seek lands at the correct time, not approximately)** and AC8's confirmation
that Opus is windowable (index resolver + sidecar setup header) so Phase 21 can build on it. **Depends on
18.118.4.**
- **18.6 — Public Settings menu + the quality toggle (the listener selection UX).** The new public-site
Settings-menu shell (§4a): an app-bar trigger + MudBlazor menu hosting a settings-item abstraction, the
`PublicSiteSettings`/`ListenerSettings` object, and the dark-mode-pattern persistence seam (`streamQuality`
cookie + a `DeepDrftPublic` prerender-read service + `PersistentComponentState` bridge + client cookie
service). The **quality toggle is its first occupant** (Low-data/Lossless, Opus default, capability-gated),
driving the `?format=` the player sends (needs 18.3). Built design-for-adaptability so dark mode can plug
in later without restructuring (not migrated now). **Depends on 18.3** (the toggle needs the format
mechanism); the menu *shell* can be built ahead of that. *Splittable* into "menu shell" + "toggle plugs
in" if Daniel wants the shell proven first — but small enough to land together (§4a).
---
## 9. Cross-references (read before implementing)
- `CONTEXT.md §5` "Non-WAV formats" — the deferred intent this phase realizes (now concrete: derived
Opus low-data path, not generic format support).
- `PLAN.md` Phase 21 / `product-notes/phase-21-windowed-streaming-buffer.md` — **sequenced AFTER this
phase.** Phase 21's C5 invariant ("WAV-only shipping target; must not foreclose MP3/FLAC") is now
driven by Opus's VBR/paged seek math; Phase 21 OQ5 (adopt MSE) is resolved **NO** — the bespoke
graph stays (the same C1 decision recorded here). Windowing a VBR/Opus stream uses
`OpusFormatDecoder.calculateByteOffset`'s **accurate index-based mapping** (§3.4a — *not* the earlier
"approximate page-interpolation"; that language in the Phase 21 doc is corrected). Phase 21's windowed
refill calls the **same** index resolver an explicit seek does (§3.4a D), and a window that opens away
from byte 0 still decodes via the sidecar setup header (UC9).
- `PLAN.md` Phase 4 (landed) / `COMPLETED.md` — the HTTP `Range: bytes=X-` primitive Opus seek reuses.
- `PLAN.md` Phase 1.5 (gapless) / 1.6 (track-skip on error) / 1.7 (Safari) — 1.5's "encoder
padding/priming" caveat applies to Opus (it has pre-skip samples in `OpusHead`); 1.6's
byte-scan-to-next-frame is the Ogg-page-sync analogue; 1.7's Safari floor intersects §3.4's Ogg-Opus
`decodeAudioData` support (Safari < 18.4).
- `PLAN.md` Phase 12 / `product-notes/phase-12-waveform-visualizer-generalization.md` — the
`WaveformProfileService` derived-artifact-at-ingest + regenerate pattern this transcode mirrors
(compute on upload, regenerate via CMS action / endpoint, its own `track-waveforms` vault → the S2
precedent).
- `PLAN.md` Phase 9 — defines the `Mix` medium (single long track), the canonical low-data case.
- `PLAN.md` Phase 16 — play/share telemetry keys on one track identity; the §3.6 road-not-taken
(one-row-per-format) would have fractured this — kept to one identity, two artifacts.
- `DeepDrftContent/Processors/AudioProcessor.cs` + `AudioProcessorRouter` + `DeepDrftContent/CLAUDE.md`
— the existing format-router and the `WaveformProfileService` derived-artifact seam; 18.1 lives here.
- `DeepDrftPublic/Interop/audio/IFormatDecoder.ts` — the strategy interface `OpusFormatDecoder`
implements; `FlacFormatDecoder.ts` is the nearest prior art (setup-bytes carry + frame-sync scan).
- `DeepDrftPublic/Interop/audio/AudioPlayer.ts` (`createFormatDecoder`, lines 117125) — the decoder
registry gaining the Opus arm.
- `DeepDrftPublic.Client/Clients/TrackMediaClient.cs` + `DeepDrftPublic/Controllers/TrackProxyController.cs`
— the media fetch + proxy that thread the new `?format=` param (mirroring `offset`), and proxy the new
seek/setup sidecar fetch.
- Root `CLAUDE.md` "Theming and dark mode" + `DarkModeService` (in `DeepDrftPublic`) + `DarkModeSettings`
(`DeepDrftPublic.Client.Common`) — the cookie → prerender-read → `PersistentComponentState` → client
cookie-service seam the **streaming-quality preference** (§4a) mirrors exactly; the eventual dark-mode-
into-the-Settings-menu migration consolidates two copies of this seam.
- `DeepDrftPublic.Client` `NowPlayingStats.razor` / `StatsClient` — the `PersistentComponentState`
prerender-bridge precedent (prerender fetch carried into WASM without a re-fetch/flash), the pattern the
quality preference's bridge follows; see the `tracksview-persistent-state-seam` auto-memory.
## Sources
- Ogg Opus support in `decodeAudioData`: Chrome/Firefox long-standing; Safari added Ogg-Opus at 18.4
(macOS 15.4 / iOS 18.4, March 2025) — prior Safari decoded Opus only in CAF.
https://chromestatus.com/feature/5649634416394240 ;
https://www.testmuai.com/learning-hub/opus-audio-codec-browser-support/
@@ -0,0 +1,383 @@
# Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)
Product spec. Status: **design / framing — implementation-ready pending Daniel's open-question calls.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
Surface: **public listener site only** (`DeepDrftPublic.Client` player stack + `DeepDrftPublic`
TypeScript audio interop). No CMS (`DeepDrftManager`) change. No data-model or schema change. The one
server touch is **reuse, not new surface**: the existing `DeepDrftAPI` HTTP `Range: bytes=X-`
partial-content primitive (Phase 4, landed) is the load-bearing dependency; this phase adds no new API
endpoint.
> **Sequencing dependency (Daniel, 2026-06-23): Phase 18 (Opus Low-Data Streaming) comes BEFORE this
> phase.** Format support — specifically the derived **Ogg Opus fullband 320** low-data delivery path
> (`product-notes/phase-18-opus-low-data-streaming.md`) — is a prerequisite that sequences ahead of
> windowing. Phase 21's windowing must work across **both** delivery formats (lossless WAV and Opus).
> Its C5 invariant below already anticipated this ("must not foreclose MP3/FLAC"); **Opus is now the
> concrete VBR/containerized driver of C5.** Windowing an Opus stream uses the decoder's **accurate
> index-based** byte↔time mapping (`OpusFormatDecoder.calculateByteOffset` — a binary search in the Phase 18
> precomputed seek index), exactly the C5 case — *not* the exact CBR-WAV `byteRate` math, and *not*
> approximate Ogg-page interpolation. **Correction (Daniel, 2026-06-23):** an earlier draft described the
> Opus mapping as "approximate page interpolation"; the Phase 18 seek-model resolution rejected that — Opus
> seeking is **accurate**, backed by a precomputed seek index built at transcode time, so refill resolves to
> the *exact* page offset. The windowed refill controller calls the **same** index resolver an explicit seek
> does (Phase 18 §3.4a D); a window opening away from byte 0 still decodes via the Phase 18 sidecar setup
> header. Build the window machinery format-agnostically (§2 C3/C5) so it inherits Opus for free.
---
## 1. Goal
Bound the **client memory** a playing track consumes to a small, configurable forward window —
**independent of total stream length** — so a 1 GB+ DJ MIX (Phase 9 `Mix` medium: a single long track)
plays without the whole decoded PCM accumulating in the browser.
**The defect, stated precisely.** The network path already streams in adaptive 1664 KB chunks
(`StreamingAudioPlayerService.StreamAudioWithEarlyPlayback`) — that part is fine. The accumulation is on
the **decode side**: `PlaybackScheduler` holds `private buffers: AudioBuffer[]` and **never evicts**
("Supports pause/resume/seek by **retaining all buffers**" — its own doc comment). Every 64 KB segment
the `StreamDecoder` decodes is pushed via `addBuffer()` and kept for the life of the track. Decoded PCM
is **larger than the compressed-or-raw source** in memory (Web Audio `AudioBuffer` is 32-bit float per
sample per channel — a 16-bit stereo WAV roughly **doubles** in size once decoded), so a 1 GB WAV becomes
~2 GB of retained `AudioBuffer` float data. That is the OOM.
**One-line framing:** today the player decodes the whole track into memory and keeps it; Phase 21 makes
it keep only a sliding forward window and discard what has already played, refilling on demand from the
Range primitive it already uses for seek.
---
## 2. Constraints / invariants (the contract that must hold)
These are non-negotiable. The §3.5 streaming seam (root `CLAUDE.md` "Streaming-first audio playback";
`CONTEXT.md §3.5`) is called *the most architecturally load-bearing part of the playback path* by both
docs. This phase **modifies that seam** — so the contract it must preserve is spelled out here.
- **C1 — The seek-beyond-buffer Range path is the substrate, kept intact.** Phase 4 landed HTTP
`Range: bytes={offset}-``206 Partial Content` end to end (client `TrackMediaClient`
`DeepDrftPublic` proxy → `DeepDrftAPI`), and `StreamDecoder.reinitializeForRangeContinuation` retains
the parsed format header on a continuation body (no re-parse). Windowed refill is a **generalization of
this exact path** (§3.1) — it must not require a second, divergent fetch mechanism.
- **C2 — Playback start latency unchanged.** Today playback starts as soon as a configurable minimum
buffer count is queued (header-derived duration, not full-file). The window model must keep first-audio
latency at parity — bounding memory must not reintroduce a fetch-then-play stall.
- **C3 — The format-decoder abstraction is untouched.** `IFormatDecoder` owns all format-specific
byte math; `AudioPlayer.createFormatDecoder` already dispatches on `Content-Type` (WAV/MP3/FLAC
decoders all wired today — verified 2026-06-23; an `OpusFormatDecoder` joins them in Phase 18).
Windowing lives in the
**format-agnostic** layer (`PlaybackScheduler` eviction + `StreamDecoder`/player refill
orchestration); it must add **no** format-specific branches. A future wired MP3/FLAC decoder inherits
windowing for free.
- **C4 — Read-only playback only.** This is a memory-management change, not a UX change. No new
user-visible control, no change to seek/transport semantics beyond what the listener already
experiences. Seek must still feel identical.
- **C5 — Must window both delivery formats (WAV lossless AND Opus low-data).** Byte↔time mapping for
refill is exact and cheap for WAV (CBR: `byteRate` from the header). **Phase 18 (Opus) is sequenced
before this phase and is the concrete VBR driver here** — and its mapping is **also exact**, but by a
different mechanism: an Ogg Opus 320 stream has no linear time↔byte relationship, so
`OpusFormatDecoder.calculateByteOffset` resolves via a **precomputed seek index** (granule→byte, built at
transcode; Phase 18 §3.4a), a binary search that returns the exact page offset — **not** an approximate
page interpolation. (An earlier draft of this invariant said "approximate"; the Phase 18 seek-model
resolution, Daniel 2026-06-23, made Opus seeking accurate. Corrected here.) The window machinery must
express refill purely in terms of the decoder's existing `calculateByteOffset`, so the same code windows
WAV (via `byteRate`) and Opus (via the index) — **no WAV-special-cased offset math in the window layer**,
and no approximation for either. A window that opens away from byte 0 must also prepend the decoder's
retained/sidecar setup header (Phase 18 §3.4a B) — the format-agnostic refill path already routes
continuations through the decoder's header-carry, so this comes for free. (MP3/FLAC decoders are already
wired in the registry too — the registry dispatches on content-type today; an `OpusFormatDecoder` joins
them in Phase 18.)
- **C6 — No regression to the single-instance JS decoder concurrency guarantees.** The current code is
careful that only one streaming loop touches the single JS `StreamDecoder` at a time
(`DrainActiveStreamingTaskAsync`, the `_streamingCancellation` identity dance). Windowed refill
introduces *more* mid-stream fetches; it must route through the **same** drain/cancellation discipline,
not around it.
- **C7 — The Mix visualizer's data source is independent and must stay that way.** The Phase 10/12
WebGL2 lava visualizer renders from a **preprocessed high-res waveform datum** fetched per-track
(`GET api/track/{entryKey}/waveform/high-res`), **not** from live decoded PCM. Confirmed: evicting
played `AudioBuffer`s cannot starve the visualizer — it never read them. The window model is invisible
to the visualizer. (This is the canonical 1 GB case *and* the case that proves the eviction is safe.)
---
## 3. Architectural shape
### 3.0 The mental model
A track's audio is a byte range `[0, fileLength)` on disk. At any moment the listener is at playback
position `P` (seconds → byte offset via the format decoder). The player should hold decoded
`AudioBuffer`s only for a bounded window roughly `[P - back, P + ahead]`:
- **forward fill (`ahead`)** — enough decoded lookahead that playback never starves (covers the existing
500 ms scheduler lookahead plus network jitter headroom);
- **back-retain (`back`)** — a small amount of *already-played* audio kept so a short seek-back does not
trigger a network refetch;
- **evict** — anything older than `P - back` is dropped (`AudioBuffer` references released → GC reclaims
the float data);
- **refill** — when forward decoded lookahead drops below a low-water mark, fetch+decode more from the
current byte position; when the window's tail is evicted and the listener seeks back past it, refetch
that region via the Range primitive (the seek-beyond-buffer path, run *backwards*).
This is a **ring/sliding-window buffer keyed on playback position**, driven by high/low-water marks —
the standard bounded-producer/bounded-consumer pattern, transplanted onto the decode→schedule seam.
### 3.1 Why this is a generalization of seek-beyond-buffer, not a new mechanism
The seek-beyond-buffer path already does **every primitive** the window needs, just triggered manually
and one-shot:
| Window operation | Existing seek-beyond-buffer machinery it reuses |
|-------------------------------|-----------------------------------------------------------------------------------|
| Discard buffers, keep offset | `PlaybackScheduler.clearForSeek()` + `setPlaybackOffset()` (clears buffers, retains the absolute-time anchor) |
| Fetch from a byte offset | `TrackMediaClient.GetTrackMedia(key, byteOffset)``Range: bytes=X-` → 206 |
| Decode a header-less body | `StreamDecoder.reinitializeForRangeContinuation(remainingByteLength)` |
| Map time → byte offset | `StreamDecoder.calculateByteOffset()``IFormatDecoder.calculateByteOffset()` |
| Single-loop safety on refetch | `_streamingCancellation` swap + `DrainActiveStreamingTaskAsync()` |
The difference is **eviction does not exist yet** (the scheduler only ever `clear()`s wholesale) and
**refill is one-shot** (a seek, not a continuous low-water-triggered loop). So the new work is two
seams: a *partial-evict* on the scheduler, and a *position-driven refill controller* on the player. The
fetch/decode/offset plumbing is reused verbatim.
### 3.2 The three candidate directions
Per file convention the alternatives are recorded; the recommendation follows.
**Direction A — Sliding window on the existing single forward stream (recommended).**
Keep the current model where the C# loop reads one forward HTTP stream and pumps chunks into the JS
decoder. Add two things: (1) `PlaybackScheduler` gains *partial eviction* — drop buffers whose
absolute-time end is older than `P - back`, adjusting its index bookkeeping so `getCurrentPosition()`
and scheduling stay correct against a buffer array that no longer starts at index 0; (2) a
*back-pressure* signal — when forward decoded lookahead exceeds the high-water mark, the C# loop
**pauses reading** the HTTP stream (stops calling `ReadAsync`) until playback drains it below low-water,
then resumes. Memory is bounded by high-water + back-retain. Seek-back beyond the retained window falls
through to the **existing** seek-beyond-buffer path unchanged.
*Why recommended:* smallest change to the load-bearing seam; reuses the live forward stream (no extra
connections in the common case); eviction and back-pressure are the only genuinely new mechanisms, and
both are local (one to the scheduler, one to the read loop). Back-pressure via "stop reading the socket"
is exactly how TCP flow control already wants to behave — pausing `ReadAsync` lets the kernel window
close; we are not fighting the transport.
**Direction B — Discrete window segments, each its own Range fetch.**
Treat the file as fixed-size byte segments (e.g. 4 MB). Hold N decoded segments around `P`; fetch the
next/previous segment via a fresh Range request as the window slides; discard the far segment. No live
long-lived forward stream — every window is an independent 206.
*Why not (default):* turns one connection into many short Range requests (more proxy hops through
`DeepDrftPublic`, more server-side `WavOffsetService`-style header synthesis, more places a fetch can
fail mid-stream — worsening the §1.6 error surface), and the byte↔time segment math must be exact at
every boundary. It *is* the cleaner model for true random-access (and the better base if seeking-heavy
usage dominates), so keep it as the fallback if Direction A's back-pressure proves leaky in practice.
Borrowed prior art: HLS/DASH segment windows and the MSE `SourceBuffer.remove()` eviction model — this
is how every production HTML5 adaptive player bounds memory. We are doing the hand-rolled equivalent
because the stack is a bespoke Web Audio graph, not `<media>` + MSE.
**Direction C — Adopt MediaSource Extensions (MSE) and let the browser manage the buffer.**
Stop hand-rolling the decode→schedule graph for long tracks; feed the Range stream into a `SourceBuffer`
and let the browser evict via its built-in quota + `remove()`. Memory management becomes the platform's
problem.
*Why not — RESOLVED, rejected (Daniel, 2026-06-23; see OQ5):* MSE does not accept raw WAV/PCM — it
wants containerized formats (fragmented MP4/WebM, or MP3/AAC elementary streams). The entire bespoke
visualizer/spectrum graph is wired to the Web Audio `AudioContext`, not a `<media>` element. Adopting
MSE is a **rewrite of the playback substrate**, not a windowing change. It *looked* like the real
long-term answer once compressed delivery arrived — but Daniel has decided compressed delivery
(**Phase 18 Opus**) will feed the **same bespoke graph** via the `IFormatDecoder` seam, so the
compressed-delivery move that would have justified MSE happens *without* surrendering the graph. **The
bespoke graph is a deliberate long-term commitment; MSE is rejected.** Direction A is therefore the
permanent destination, not a stopgap that MSE will retire. Recorded as considered-and-declined.
### 3.3 Recommended direction: A, with B held as the documented fallback
Direction A is the smallest coherent change that hits the headline (bounded memory under a 1 GB stream)
while honoring C1C7. It keeps the live forward stream, reuses the seek-beyond-buffer path for the only
genuinely random-access case (seek-back past the retained tail), and isolates the two new mechanisms.
**The final architecture and the exact eviction/back-pressure API are staff-engineer's call at
implementation** (per file convention); this spec fixes the *shape* and the invariants, not the method
signatures.
### 3.4 SOLID / road-not-taken rationale
- **SRP, preserved.** Eviction is a `PlaybackScheduler` concern (it already owns buffer storage); refill
orchestration is a player-service/`StreamDecoder` concern (they already own the fetch loop); byte↔time
math stays in `IFormatDecoder`. No responsibility crosses a boundary it does not already own.
- **OCP, via C3/C5.** Windowing added in the format-agnostic layer means wiring MP3/FLAC later changes
zero window code. The window expresses refill through `calculateByteOffset` — the one seam the
decoders already implement.
- **The seam stays single-writer (C6).** Every new refetch routes through the existing
cancellation/drain discipline, so "only one loop touches the JS decoder" remains true. This is the
rule most likely to be violated by a naive implementation and is called out as a hard invariant.
- **Road not taken — eager full decode with a memory cap that just stops decoding.** Tempting (decode
until you hit a byte budget, then stop) but it breaks playback of long tracks past the cap entirely —
it bounds memory by *refusing to play the rest*, not by sliding. Rejected: it is a degradation, not a
feature.
---
## 4. Use cases
- **UC1 — Play a 1 GB+ DJ MIX start to finish (the headline).** Memory stays bounded throughout; the
listener experiences continuous playback identical to a short track.
- **UC2 — Seek forward within a long track.** Already handled by seek-beyond-buffer; under windowing the
forward seek clears the window and refills at the target — no behavior change, now with eviction so the
pre-seek region does not linger.
- **UC3 — Seek back a few seconds.** Served from the back-retain window with **no** network refetch
(the reason `back` exists).
- **UC4 — Seek back far, past the evicted tail.** Falls through to the existing seek-beyond-buffer Range
fetch, run toward an earlier offset. (Open question OQ2 — see §6.)
- **UC5 — Pause a long track for a long time.** Memory stays at the bounded window size while paused (no
continued decode). On resume, forward fill restarts from the low-water trigger.
- **UC6 — Mix detail page with the lava visualizer running.** Visualizer reads its preprocessed datum
(C7); windowing is invisible to it. Confirmed non-interaction.
---
## 5. Interaction with the deferred Phase 1 streaming features
This phase touches the **same decoder/scheduler seam** as the deferred Phase 1.3/1.4/1.5 items and the
1.6/1.7 robustness items. The interactions, explicitly:
- **1.3 Preload / prefetch (deferred; preload half).** *Shares machinery, does not conflict — and should
be sequenced after.* Preload stages the **next track** into a second decoder instance during the
current track's tail; windowing bounds the **current track's** forward buffer. They are orthogonal
axes (next-track vs. current-track-window), but they compound the memory question: a naive preload of a
second 1 GB mix would reintroduce the OOM this phase fixes. **Recommendation: land windowing first**,
so that when preload arrives, the staged next-track decoder is *also* windowed by construction (it
inherits the bounded scheduler). Windowing makes preload *safe for long tracks*; without it, preload of
mixes is a memory hazard.
- **1.4 Crossfade (deferred).** Needs two simultaneous `PlaybackScheduler` instances briefly overlapping.
Both would be windowed instances — the overlap doubles the *window* size momentarily, not the whole
track. Windowing makes crossfade between two long mixes affordable. No reordering needed; 1.4 still
gates on 1.3.
- **1.5 Gapless (deferred).** Sample-accurate hand-off of the next track's first buffer at the current
track's last buffer. Windowing changes *which* buffers are retained but not the hand-off mechanism;
the only care point is that the current track's **final** window must not be evicted before the gapless
boundary is scheduled. A minor invariant for whoever builds 1.5, not a blocker. Note 1.5's existing
WAV-only caveat stands.
- **1.6 Track-skip on error (deferred).** *Windowing enlarges the error surface — call this out.* Today
a fetch failure happens at load (one fetch) or at a user seek (one fetch). Windowed refill issues
**mid-stream** fetches the listener did not initiate; one of those can fail at byte 700 M of a 1 GB
mix. So Phase 21 should ship with at least the *cheap* half of 1.6: a mid-stream refill failure must
**surface a clear error and not wedge the player** (it must not leave playback "running" with a starved
scheduler — mirror the `playFromPosition` end-of-buffer recovery already in `PlaybackScheduler`). The
rich half (byte-scan to next valid frame) stays deferred. **Recommendation: fold the minimal refill-
failure handling into Phase 21's acceptance criteria** (AC6) rather than leaving it entirely to 1.6 —
it is created by this phase.
- **1.7 Safari compatibility (deferred).** Windowing adds no new Safari-specific surface beyond what the
streaming path already has. The one adjacency: more frequent `AudioContext` activity during refill
should be checked against the older-Safari `webkitAudioContext` quirks when 1.7 is addressed — note it,
do not block on it.
---
## 6. Open questions for Daniel (genuine product decisions, not implementation detail)
These are policy calls with user-visible or resource trade-offs — flagged rather than decided here.
- **OQ1 — Window size policy.** What bounds the window — a **fixed byte/time budget** (e.g. "hold at
most ~30 s decoded ahead + ~10 s behind"), or a **configurable memory budget** (e.g. "≤ N MB of
decoded PCM") that derives the time window from the stream's byte rate? Recommend a **time-based
forward window + small time-based back-retain** as the primary knob (intuitive, format-portable), with
a hard **memory ceiling** as a secondary guard. The exact numbers are tunable post-landing; Daniel
picks the *policy axis*. `[Daniel decision]`
- **OQ2 — Seek-back past the evicted window.** When the listener seeks back earlier than the retained
tail, we must refetch (the audio is gone). Acceptable to take the same brief re-buffer the forward
seek-beyond-buffer takes today? (Recommend yes — it is the symmetric case and listeners already accept
it forward.) Or should back-retain be generous enough that this is rare? `[Daniel decision]`
- **OQ3 — Configurable total in-flight memory cap.** Should there be a single hard byte ceiling on total
decoded audio held by the player (a safety net independent of the window-size policy), exposed as a
config value? Recommend **yes, as a guard rail** even if the window policy is time-based — it is the
backstop that makes "1 GB stream never OOMs" a guarantee rather than a tuning hope. `[Daniel
decision]`
- **OQ4 — Apply windowing to all tracks, or only long ones?** A 3-minute Cut decoded whole is ~3060 MB
— harmless today. Windowing everything is simpler (one code path) but adds refill machinery to short
tracks that never needed it. Recommend **window everything** (one path, C6-safe, and short tracks
simply never hit a refill because they fit inside the forward window) — but Daniel may prefer a
size threshold. `[Daniel decision]`
- **OQ5 — Is MSE (Direction C) the real destination? — RESOLVED: NO (Daniel, 2026-06-23).** **Do not
adopt MSE. The bespoke Web Audio decode→schedule graph stays — it is bespoke by deliberate choice, a
long-term commitment, not a stopgap.** Daniel's rationale: the player is intentionally a custom
graph, not an HTML `<media>` element; the compressed-delivery move that *would* have made MSE
tempting is being met instead by **Phase 18 (Opus low-data path)** feeding the **same bespoke graph**
through the `IFormatDecoder` seam — so compressed delivery arrives *without* surrendering the graph.
Consequence for this phase: Direction A (the hand-rolled sliding window) is the destination, not a
placeholder; invest in it as permanent machinery. It will window both the WAV and the Opus path
(the sequencing note at the top). Direction C is recorded as **considered and declined** per file
convention; kept visible so a future reader sees the road not taken and why.
`[RESOLVED — bespoke graph retained; MSE rejected]`
---
## 7. Acceptance criteria
- **AC1 (headline) — Bounded memory under a 1 GB stream.** Playing a 1 GB+ WAV mix start to finish, the
browser tab's retained decoded-audio memory stays bounded to the configured window (not growing toward
~2 GB). Verifiable via browser memory tooling: peak decoded-audio footprint is independent of track
length and tracks the window-size policy, not the file size.
- **AC2 — Playback-start latency at parity (C2).** First-audio latency for a track is unchanged from
pre-windowing (within noise). Windowing does not introduce a fetch-then-play stall.
- **AC3 — Continuous playback, no starvation.** A long mix plays edge to edge with no audible gaps,
underruns, or stalls under normal network conditions — the forward fill stays ahead of the playhead.
- **AC4 — Seek-back within the window is instant (UC3).** A short backward seek into retained audio
produces no network request.
- **AC5 — Seek (forward, and back past the window) still works (UC2/UC4).** Both resolve via the
existing Range path with the same behavior the listener sees today; the pre-seek region is evicted, not
retained.
- **AC6 — A mid-stream refill failure degrades cleanly (the 1.6 adjacency).** A failed refill fetch
surfaces a clear user-visible error and leaves the player in a recoverable state (not a wedged
"playing" with a starved scheduler). It must not silently hang.
- **AC7 — The Mix visualizer is unaffected (C7).** With the lava visualizer running on a long mix, the
visualizer renders identically (it reads the preprocessed datum, never the evicted buffers).
- **AC8 — Single-decoder concurrency invariant holds (C6).** Under rapid seek + refill activity, no
interleaved `ProcessStreamingChunk` calls corrupt the single JS decoder (the existing drain/cancel
discipline still governs every fetch).
---
## 8. Wave decomposition
Dependency shape: `21.1 → 21.2 → 21.3`, with `21.4` validating the whole. 21.1 is the cold-start
prerequisite and the load-bearing change; the rest layer on it.
- **21.1 — Partial eviction in `PlaybackScheduler` (cold-start; the load-bearing change).** Give the
scheduler the ability to drop already-played buffers and keep its position/index bookkeeping correct
against a buffer array that no longer begins at absolute time 0 (today `getCurrentPosition`,
`playFromPosition`, and the scheduling loop all assume `buffers[0]` is the track start). This is the
hardest correctness work in the phase — the time-anchor math must stay exact through eviction. No
refill yet; with eviction alone and the forward read loop unchanged, this is provably memory-bounded
for the *played* region. **Independent of the §6 open questions** — it can begin immediately; the
window *sizes* (OQ1/OQ3) are parameters fed in later. Settled and cold-start.
- **21.2 — Back-pressure on the forward read loop (the bound on the *unplayed* region).** Make the C#
`StreamAudioWithEarlyPlayback` loop stop calling `ReadAsync` when forward decoded lookahead exceeds the
high-water mark, and resume below low-water. Together with 21.1, this bounds *both* the played and
unplayed sides — the full memory guarantee (AC1). Must route resume/pause through the existing
cancellation-safe single-loop discipline (C6). **Depends on 21.1** (eviction must exist so the drained
region is reclaimed, not merely un-read).
- **21.3 — Seek-back-past-window refill (close the random-access case).** Wire UC4 — when a backward
seek lands earlier than the retained tail, refetch via the existing seek-beyond-buffer Range path
pointed at the earlier offset, and the minimal AC6 refill-failure handling. Mostly **reuse** of the
landed seek path; the new work is the trigger (window-miss detection) and the clean-failure path.
**Depends on 21.1 + 21.2** (needs the window boundaries they define).
- **21.4 — Validation pass against the 1 GB target (acceptance).** Exercise AC1AC8 against a real 1 GB+
mix: memory profiling (AC1), latency parity (AC2), edge-to-edge playback (AC3), the seek matrix
(AC4/AC5), induced refill failure (AC6), visualizer-running (AC7), and rapid-seek concurrency (AC8).
Largely test/measurement; any break is likely a tuning fix in the 21.1 anchor math or the 21.2
water-marks. **Depends on 21.121.3.**
---
## 9. Cross-references (read before implementing)
- Root `CLAUDE.md` "Streaming-first audio playback" / `CONTEXT.md §3.5` — the seam this phase modifies;
the §2 invariants here restate its contract. Both flag it as the most load-bearing path.
- `PLAN.md` Phase 4 (landed) / `COMPLETED.md` — the HTTP Range `bytes=X-` primitive this generalizes.
- `PLAN.md` Phase 1.3 / 1.4 / 1.5 / 1.6 / 1.7 — the deferred decoder/scheduler-seam features; §5 above
reconciles each.
- `PLAN.md` Phase 9 — defines the `Mix` medium (single long track), the canonical 1 GB case.
- `PLAN.md` Phase 10 / `product-notes/phase-10-mix-visualizer-lava-reframe.md` /
`product-notes/phase-12-waveform-visualizer-generalization.md` — establishes the preprocessed
per-track high-res waveform datum; the basis for C7 (visualizer does not read live PCM).
- `DeepDrftPublic/Interop/audio/PlaybackScheduler.ts` — owns the unbounded `buffers: AudioBuffer[]`;
21.1 lives here.
- `DeepDrftPublic/Interop/audio/StreamDecoder.ts``reinitializeForRangeContinuation`,
`calculateByteOffset`; the refill substrate.
- `DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs` — the C# forward read loop
(`StreamAudioWithEarlyPlayback`), the seek-beyond-buffer path (`SeekBeyondBuffer`), and the
cancellation/drain discipline (C6); 21.2/21.3 live here.
- `DeepDrftPublic.Client/Clients/TrackMediaClient.cs` — the Range-capable media fetch reused by refill.
@@ -0,0 +1,439 @@
# Phase 22 — SEO Metadata Component (parameterized head/meta injection, public site)
Product spec. Status: **design / framing — implementation-ready pending Daniel's open-question calls.**
Author: product-designer. Date: 2026-06-23. **No code has been written by this doc.**
Surface: **public listener site only** (`DeepDrftPublic` ASP.NET Core host + `DeepDrftPublic.Client`
Blazor WASM assembly). The CMS (`DeepDrftManager`) is an authenticated admin surface and is
**explicitly out of scope** — it must not be touched, and admin pages should if anything carry
`noindex`. No data-model or schema change. No new API endpoint (every value the component needs is
already returned by the existing `TrackDto` / `ReleaseDto` / `HomeStatsDto` reads).
---
## 1. Goal
Give every public page a **single, reusable, parameterized component** that emits the full modern-SEO
head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD —
so crawlers and social unfurlers see correct, page-specific metadata **in the prerendered HTML**, with
no per-page boilerplate and no double-maintenance.
**One-line framing:** today each page hand-writes a bare `<PageTitle>` and nothing else; Phase 22
replaces that with one `<SeoHead …>` component that a page configures with a handful of parameters (or
a typed model), defaulting everything else from a site-wide config, and that renders the complete head
surface server-side during prerender where crawlers can read it.
### What exists today (the starting point — verified 2026-06-23)
- `App.razor` (`DeepDrftPublic/Components/App.razor`) declares `<HeadOutlet @rendermode="InteractiveAuto" />`
in `<head>`, and a static `<head>` block with charset, viewport, `<base href="/">`, stylesheet links,
`<ImportMap />`, and a favicon. **No** `<meta name="description">`, **no** canonical, **no** OG/Twitter
tags, **no** JSON-LD. The only per-page head contribution anywhere is `<PageTitle>`.
- Pages set titles ad hoc: `Home.razor``<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>`;
`CutDetail.razor``<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>`. No shared
title-composition convention — the suffix (" - DeepDrft" vs " - Electronic Music Collective") is
already inconsistent.
- `<html lang="en">` is set once in `App.razor`.
- Detail pages (`CutDetail`, `SessionDetail`, `MixDetail`) inherit base classes (`ReleaseDetailBase` /
`CutDetailBase`) that load a `ReleaseDto` into a ViewModel in `OnParametersSetAsync` (not
`OnInitialized` — the documented same-template-nav reuse rule), and bridge the prerender fetch across
the WASM seam with `PersistentComponentState`. **This is the key fact for render-mode correctness
(§5):** the release data is already resolved during prerender, so the SEO tags it feeds can be too.
- Canonical URL composition already exists for releases: `ReleaseRoutes.DetailHref(entryKey, medium)`
`/cuts/{key}` | `/sessions/{key}` | `/mixes/{key}`. `SharePopover` already builds absolute share URLs
from this; the SEO canonical tag is the same URL, absolutized.
- Cover art resolves to `api/image/{Uri.EscapeDataString(release.ImagePath)}` — the OG image source.
### Why now / why it matters
A public music catalogue lives or dies on discoverability and on how its links unfurl in iMessage,
Discord, X, and search results. Right now a shared `/mixes/{key}` link unfurls as a bare title and a
URL — no description, no cover image, no rich card. Search engines see a title and an empty description.
The share affordance (Phase 16/17) is already a first-class feature; SEO/OG metadata is the missing
half of "this link is worth sharing."
---
## 2. Constraints / invariants (the contract that must hold)
- **C1 — Public site only.** Zero changes to `DeepDrftManager`. The component, its config, and its
registration all live in the public host + client. If anything, the CMS should later emit `noindex`
(noted as an adjacent concern in §7, not specified here).
- **C2 — Tags must be present at prerender time, not after WASM boot.** Crawlers and unfurlers read the
server-rendered HTML and (mostly) do not execute the WASM runtime. The component must contribute its
head content during the **server prerender pass**. This is the single most load-bearing correctness
requirement and is detailed in §5. It governs *where the data comes from* (must be resolvable during
prerender) and *how the component renders* (via `<HeadContent>` into the existing `<HeadOutlet>`).
- **C3 — One component, parameterized — DRY.** A page supplies only its own specifics; everything else
defaults from a site-wide config. No page re-declares the boilerplate set of ~15 tags. Adding a new
page type means passing a model, not copy-pasting a head block.
- **C4 — SOLID seam.** The component renders; it does not fetch. Page-level data (the `ReleaseDto`, the
home stats) is already loaded by the page's ViewModel — the SEO component is **presentational**, fed
by parameters, exactly like `ReleaseHeroOverlay` / `ReleaseDescription` / `NowShowingPanel`. Defaults
come from an injected config object, not from a data fetch inside the component.
- **C5 — No new fetch path, no new endpoint, no schema change.** Every value is already available:
`ReleaseDto` carries `Title`, `Artist`, `Genre`, `ReleaseDate`, `Description`, `ImagePath`, `EntryKey`,
`Medium`; `HomeStatsDto` backs the home page; the About page is static editorial. If a desired tag has
no source datum, it is either omitted or filled from config — **never** a reason to add a column.
- **C6 — Graceful partial data.** A release with no `Description`, no `ImagePath`, or no `Genre` must
still emit a valid, complete-as-possible head (fall back to config defaults; omit truly optional tags
rather than emit empty ones — mirror `ReleaseDescription`'s "null description renders nothing" rule).
- **C7 — Valid, non-duplicated output.** Exactly one `<title>`, one canonical, one OG block, one JSON-LD
script per page. `<PageTitle>` and the SEO component must not both emit a title — the component owns
the title (it composes `PageTitle` internally) so there is one source of truth. (See OQ4.)
---
## 3. Metadata surface (what the component emits)
The full modern set, grouped. Each row notes its **source** (page param / config default / derived) and
whether it is **always** emitted or **conditional**.
### 3.1 Standard / search
| Tag | Source | Emit |
|-----|--------|------|
| `<title>` | page (composed with config site-name suffix) | always |
| `<meta name="description">` | page; falls back to config default description | always |
| `<link rel="canonical" href>` | derived: config base URL + current path (releases via `ReleaseRoutes`) | always |
| `<meta name="robots">` | config default (`index,follow`); page may override (e.g. `noindex` on `/404`) | always |
| `<meta name="author">` / `<meta name="application-name">` | config (`Deep DRFT`) | optional |
### 3.2 Open Graph (link unfurling — Facebook/iMessage/Discord/Slack)
| Tag | Source | Emit |
|-----|--------|------|
| `og:title` | page (defaults to `<title>` sans suffix) | always |
| `og:description` | page (defaults to meta description) | always |
| `og:url` | = canonical | always |
| `og:type` | page (`website` default; `music.album` / `music.song` for releases — see §4) | always |
| `og:site_name` | config (`Deep DRFT`) | always |
| `og:image` | page (release cover → absolute `…/api/image/{path}`); falls back to config default OG image | always (default guarantees presence) |
| `og:image:alt` | page (e.g. `"{Title} cover art"`) | conditional (when image present) |
| `og:locale` | config (`en_US`) | optional |
| Music-vertical OG (`music:musician`, `music:release_date`, `music:duration`) | release params | conditional (release pages only) |
### 3.3 Twitter Card
| Tag | Source | Emit |
|-----|--------|------|
| `twitter:card` | config (`summary_large_image` when an image exists, else `summary`) | always |
| `twitter:title` / `twitter:description` | mirror OG | always |
| `twitter:image` | mirror `og:image` | always |
| `twitter:site` / `twitter:creator` | config (the collective's handle, if any) | optional — **OQ3** |
### 3.4 JSON-LD structured data (schema.org)
One `<script type="application/ld+json">` per page, shaped by page type. This is where the music domain
gets expressed richly (cuts/sessions/mixes map to schema.org music types):
- **Site-wide / home** — `MusicGroup` (the Deep DRFT collective): `name`, `url`, `genre`, `description`,
`logo`, optional `sameAs` (social links — **OQ3**). Optionally a `WebSite` node with `potentialAction`
search (only if a public search surface exists — it does not today; defer).
- **Cut detail** (`/cuts/{key}`, a studio release, possibly multi-track) — `MusicAlbum`:
`name`=Title, `byArtist``MusicGroup`, `albumProductionType``StudioAlbum`, `datePublished`=ReleaseDate,
`genre`, `image`=cover, `url`=canonical, and `track`→ ordered list of `MusicRecording` (the album's
tracks; the page already holds `ViewModel.Tracks` in `TrackNumber` order).
- **Session detail** (`/sessions/{key}`, a live release) — `MusicAlbum` with `albumProductionType`
`LiveAlbum` (schema.org has `LiveAlbum`), or a `MusicEvent`/`MusicRecording` hybrid. **Recommend
`MusicAlbum`+`LiveAlbum`** for parity with cuts and because the catalogue treats a session as a
release, not a calendar event. (Revisit if a live *schedule* page lands — see §7.)
- **Mix detail** (`/mixes/{key}`, a single long continuous track) — `MusicRecording` (one recording,
not an album): `name`, `byArtist`, `duration` (ISO-8601 from `DurationSeconds`), `genre`, `image`,
`url`. A mix is the cleanest single-`MusicRecording` case.
- **About** (`/about`) — `AboutPage` referencing the `MusicGroup`, or simply the `MusicGroup` node again
with the editorial bio as `description`.
- **Browse pages** (`/cuts`, `/sessions`, `/mixes`, `/archive`) — `CollectionPage`, optionally with an
`ItemList` of the releases shown. Lighter touch; the detail pages carry the rich per-release schema.
> JSON-LD is the highest-leverage, music-specific part of this spec and the part most worth getting
> right. It is also the part with the most modeling latitude — the exact node shapes above are a
> **recommendation**; the precise schema.org property set is a refinement staff-engineer can tune
> against Google's Rich Results test (see §8 AC5). The spec fixes *which schema type maps to which
> medium* (the product decision) and leaves property-level polish to implementation.
---
## 4. Component design (the contract)
### 4.1 Shape: one component + a typed model + a config
Three pieces, each a clean SOLID responsibility:
1. **`SeoHead.razor`** — the single reusable presentational component. Lives in `DeepDrftPublic.Client`
(it must render in the WASM-shared component graph so it works in both prerender and interactive
passes — see §5). Renders a `<PageTitle>` and a `<HeadContent>` block containing all of §3. Owns no
data fetch and no business logic. Parameterized over a model (below). It reads the injected
`SeoOptions` for defaults and `NavigationManager` for the current absolute URL (canonical/`og:url`).
2. **`SeoModel`** (a record/class in `Common/`) — the typed per-page input. Rather than ~15 loose
`[Parameter]`s, the page hands `SeoHead` one model. Suggested surface:
- `Title` (string, required) — page title sans site suffix.
- `Description` (string?) — falls back to `SeoOptions.DefaultDescription`.
- `CanonicalPath` (string?) — defaults to `NavigationManager`'s current relative path; release pages
pass `ReleaseRoutes.DetailHref(...)` so the canonical is stable regardless of alias routes
(`/tracks/...` redirects, query strings).
- `ImagePath` (string?) — relative cover path; component absolutizes to `…/api/image/{escaped}`;
falls back to `SeoOptions.DefaultImageUrl`.
- `OgType` (enum/string, default `Website`).
- `Robots` (string?, default from config `index,follow`).
- `JsonLd` (a `RenderFragment` **or** a typed structured-data object) — the page supplies its
schema.org node; see 4.3 for the build-vs-pass decision (OQ5).
- Music-specific optionals used only by release pages: `Artist`, `Genre`, `ReleaseDate`,
`DurationSeconds`, and (for albums) the ordered track list.
A small set of **named factory helpers** keeps call sites terse and DRY — e.g.
`SeoModel.ForRelease(ReleaseDto, tracks?)`, `SeoModel.ForHome(HomeStatsDto)`, `SeoModel.ForAbout()`,
`SeoModel.ForBrowse(medium)`. Each factory encodes the medium→`OgType`→JSON-LD mapping from §3.4 in
exactly one place (DRY: a page never re-derives "a mix is a `MusicRecording`"). These factories are
pure functions over DTOs the page already holds — unit-testable without rendering.
3. **`SeoOptions`** (config, in `Common/`) — site-wide defaults: `SiteName` (`Deep DRFT`),
`TitleSuffix`, `DefaultDescription`, `BaseUrl` (the canonical production origin — **OQ1**),
`DefaultImageUrl`, `TwitterSite`/`TwitterCreator` (**OQ3**), `DefaultRobots`, `Locale`, `Genre`,
social `sameAs` links (**OQ3**). Registered in `Startup.ConfigureDomainServices` (the existing seam
that runs in **both** server and WASM `Program.cs`, per the project's static-Startup convention).
Source values from `appsettings.json` server-side; the WASM pass either hardcodes the same constants
or receives them via the existing config seam. **Note:** these are non-secret brand constants — they
belong in `appsettings.json` / a constants class, not `environment/` secrets.
### 4.2 How pages supply their specifics (DRY in practice)
- **Home** (`Home.razor`): `<SeoHead Model="SeoModel.ForHome(stats)" />` — replaces the current bare
`<PageTitle>`. Description from config or a curated home string; JSON-LD = `MusicGroup`.
- **About** (`/about`): `<SeoHead Model="SeoModel.ForAbout()" />` — static; description = the bio
lede; JSON-LD = `MusicGroup`/`AboutPage`.
- **Release detail** (`CutDetail`/`SessionDetail`/`MixDetail`): `<SeoHead Model="@_seo" />` where
`_seo = SeoModel.ForRelease(ViewModel.Release, ViewModel.Tracks)` — set once the release is resolved.
The factory reads `Medium` and picks `MusicAlbum` (cut/session) vs `MusicRecording` (mix), the
`og:type`, the canonical via `ReleaseRoutes`, and the cover image. **One call site, all 15+ tags.**
- **Browse** (`AlbumsView`/`SessionsView`/`MixesView`/`ArchiveView`): `SeoModel.ForBrowse(medium)`.
- **404** (`NotFound`): `SeoModel` with `Robots = "noindex,follow"`.
Each page touches **one line**. The boilerplate lives in `SeoHead` + the factories; the per-page values
flow in through the model. That is the DRY mechanism C3 demands.
### 4.3 Build-vs-pass for JSON-LD (the one genuine design fork — OQ5)
Two ways to produce the `<script type="application/ld+json">` body:
- **(a) Typed builder:** `SeoModel` carries strongly-typed structured-data objects (small C# records
mirroring the schema.org nodes) that a serializer renders to JSON. **Pros:** type-safe, unit-testable,
DRY (the medium→type mapping is C# in the factories), no hand-written JSON in pages. **Cons:** a small
amount of schema.org-shaped record plumbing to build once.
- **(b) RenderFragment / raw string:** the page hands `SeoHead` a pre-built JSON-LD fragment. **Pros:**
trivial component. **Cons:** pushes JSON authoring into pages (violates C3/DRY), easy to get invalid,
not testable.
**Recommend (a)** — the typed builder. It is the only option that honors DRY (the medium→schema mapping
must live in one place) and is testable (AC5 wants Rich-Results validity; pure builders make that a unit
test, not a manual check). The record set is small (a `MusicGroup`, a `MusicAlbum` with a `track` list,
a `MusicRecording`) and confined to `Common/`. **This is the load-bearing implementation choice and is
recorded as OQ5 for Daniel to confirm.**
### 4.4 SOLID / road-not-taken rationale
- **SRP:** `SeoHead` renders; `SeoModel` factories map DTOs→SEO shape; `SeoOptions` holds defaults;
pages fetch (already do). No responsibility crosses a boundary it does not already own — identical to
the `ReleaseHeroOverlay`/`ReleaseDescription` presentational-component pattern already in the codebase.
- **OCP:** a new page type or a fourth medium adds a factory method, not a new tag block. The medium
switch lives next to `ReleaseRoutes`' existing medium switch (same "Cut is the default arm so a gap is
build-visible" discipline).
- **DRY:** the ~15-tag boilerplate exists once. Pages pass values; the suffix inconsistency observed
today (`- DeepDrft` vs `- Electronic Music Collective`) is resolved by `SeoOptions.TitleSuffix`.
- **Road not taken — a server-side middleware/filter that rewrites `<head>`.** Tempting (one place,
zero component change) but it cannot see Blazor's per-page render state cleanly, fights the
`HeadOutlet` mechanism Blazor provides for exactly this, and would be a parallel metadata path the
team has to reason about separately. Rejected: use the framework's head seam, not an HTTP filter.
- **Road not taken — per-page hand-written `<HeadContent>` blocks (no shared component).** This is just
the status quo extended; it is the boilerplate-duplication C3 forbids. Rejected.
---
## 5. Render-mode correctness (the load-bearing requirement — C2)
This is the part most likely to be got subtly wrong, so it is spelled out.
**The mechanism.** Blazor's `<HeadContent>` projects child content into the `<HeadOutlet>` declared in
`App.razor`. During the **server prerender pass**, components render to HTML server-side *before* WASM
boots; their `<HeadContent>` is written into the `<head>` of the delivered document. A crawler fetching
the page sees the fully-populated `<head>` in the initial HTML response — exactly what C2 requires —
**provided the data the head depends on is available during that prerender pass.**
**Why this works here (the key enabler).** The release detail pages already resolve their `ReleaseDto`
during prerender and bridge it across the WASM seam via `PersistentComponentState` (documented in
`DeepDrftPublic.Client/CLAUDE.md` and the `tracksview-persistent-state-seam` memory). The SEO data is a
**projection of that same already-prerendered DTO.** So `SeoHead`, fed by the page's ViewModel, emits
correct tags during prerender with **no new fetch and no new bridge** — it rides the one the page
already has. This is why the component must be presentational and parameter-fed (C4): it inherits the
page's prerender-readiness for free.
**The render-mode flags to get right:**
- `<HeadOutlet>` in `App.razor` is currently `@rendermode="InteractiveAuto"`. The **prerender** of that
outlet still happens server-side (Auto prerenders on the server first), so prerendered head content is
emitted. **Confirm during implementation** that prerender is not disabled for these pages — if any SEO
page were ever set `prerender: false` (as `BatchUpload` in the CMS is), its head would be empty for
crawlers. None of the public SEO-target pages disable prerender today; the spec's requirement is that
they must not.
- **`SeoHead` lives in `DeepDrftPublic.Client`** so it participates in both the server-prerender render
tree and the WASM interactive render tree (the project's "static Startup called from both Program.cs"
convention guarantees identical DI in both passes). Putting it only in the server host would break the
interactive pass; putting it only in the client without prerender would break crawlers.
**The risk to flag — the `InteractiveAuto`/WASM boundary and double-render.** On an Auto page the head
is rendered **twice**: once server-side (prerender, what crawlers see) and again when the component
re-renders client-side after WASM boot. Two cautions:
1. **Idempotent, identical output across passes.** The tags the WASM pass produces must match the
prerender pass (same canonical, same OG, same JSON-LD), or the client re-render will replace correct
tags with different ones. Because the data is bridged via `PersistentComponentState` (not re-fetched),
the two passes see the same `ReleaseDto` and produce identical head content — **as long as the model
is built from the bridged state, not a fresh client fetch.** Guard the same way detail pages already
guard their restore: on id/key equality, to prevent cross-item bleed when prerender and WASM-boot
disagree on the current item (the documented `OnParametersSetAsync` rule).
2. **Canonical/`og:url` absolutization must not depend on a browser-only API.** During server prerender
there is no `window.location`; the absolute base must come from `SeoOptions.BaseUrl` (config), not
from JS interop. `NavigationManager` is available server-side for the *path*; the *origin* comes from
config (this is also why `BaseUrl` is OQ1 — the canonical origin is a product decision, not
discoverable at prerender from the request reliably behind the nginx proxy).
**Net:** the approach emits correct tags server-side at prerender because it projects already-prerendered
data through the framework's own head seam. The only genuinely new care points are (1) identical
output across the double render, solved by feeding from bridged state, and (2) config-sourced origin for
absolute URLs, solved by `SeoOptions.BaseUrl`.
---
## 6. Use cases
- **UC1 — A `/mixes/{key}` link pasted into Discord/iMessage unfurls richly.** Title, description, and
cover image appear in the unfurl card (OG tags present at prerender). Today: bare title + URL.
- **UC2 — Google indexes a cut with structured data.** The `MusicAlbum` JSON-LD with its `track` list is
eligible for rich results; canonical points at `/cuts/{key}` regardless of how the user arrived
(alias `/tracks/...` route, query params).
- **UC3 — The home page presents the collective.** `MusicGroup` JSON-LD + site-level OG so the root URL
unfurls and is indexed as the band's entity.
- **UC4 — A release with no cover / no description still has valid metadata.** Falls back to the config
default OG image and default description; omits `og:image:alt`; emits valid (if leaner) tags (C6).
- **UC5 — The 404 page is not indexed.** `NotFound` passes `Robots = "noindex,follow"`.
- **UC6 — Twitter/X card renders large-image.** `summary_large_image` when a cover exists, `summary`
otherwise.
---
## 7. Open questions for Daniel (product calls, not implementation detail)
- **OQ1 — Canonical production origin (`BaseUrl`).** What is the canonical public origin for absolute
canonical/`og:url`/`og:image` URLs (e.g. `https://deepdrft.com`)? This must be a fixed config value —
it cannot be reliably derived at prerender behind the nginx reverse proxy, and getting it wrong
silently breaks every absolute URL an unfurler resolves. **Also:** is there a single canonical host, or
do www/apex/staging variants need a canonical-host normalization rule? `[Daniel decision]`
- **OQ2 — Default OG image.** What is the fallback share image for pages without a cover (home, about,
browse, and cover-less releases)? A branded 1200×630 card is the OG standard. Is there an existing
brand asset to use, or does one need to be produced? Until one exists, the component can omit
`og:image` (degrades to a `summary` Twitter card) — but a default image materially improves every
unfurl. `[Daniel decision — and an asset to point at]`
- **OQ3 — Social handles / `sameAs`.** Does Deep DRFT have public social accounts (X/Twitter handle for
`twitter:site`/`creator`, and URLs for the `MusicGroup.sameAs` array)? If yes, supply them for the
config; if no, those tags are simply omitted (valid). `[Daniel decision]`
- **OQ4 — Title suffix + composition.** Standardize the title pattern: recommend `"{PageTitle} · Deep
DRFT"` (or `" - Deep DRFT"`), with the home page as a special case (`"Deep DRFT — Electronic Music
Collective"`). Confirm the suffix string and separator; this resolves the existing inconsistency
(`- DeepDrft` vs `- Electronic Music Collective`). `[Daniel decision — low stakes, pick one]`
- **OQ5 — JSON-LD: typed builder vs. passed fragment (§4.3).** Recommend the **typed builder** (option a)
for DRY + testability. Confirm, or accept the lighter passed-fragment approach if the record plumbing
is judged not worth it. `[Daniel decision — recommendation: typed builder]`
- **OQ6 — Session schema type.** Recommend modeling a Session as `MusicAlbum` + `LiveAlbum`
`albumProductionType` (parity with cuts; the catalogue treats a session as a release, not an event).
Confirm — or, if a live *schedule* surface is ever planned, a session might better be a `MusicEvent`.
`[Daniel decision — recommendation: MusicAlbum/LiveAlbum for now]`
### Adjacent but separate concerns (flagged, not specified here)
These are SEO-adjacent and worth their own small follow-ups; they are **not** in this component's scope:
- **`robots.txt`** — a static file (or a minimal endpoint) at the public host root: allow crawl, point at
the sitemap, and **disallow the CMS host** (`DeepDrftManager` is a separate app/host — its exclusion is
a deployment/robots concern, not a CMS code change). Small, separate task.
- **`sitemap.xml`** — a generated sitemap enumerating home/about/browse + every release detail URL. This
*does* need a small server-side endpoint on the public host that lists releases (reusing the existing
paged release read — no new data) and emits XML. A natural Phase 22.x follow-on, but a different unit
of work (an endpoint, not a component). Flag for Daniel: **want this folded into Phase 22 as a second
wave, or tracked as its own phase?**
- **CMS `noindex`** — ensuring `DeepDrftManager` pages are not indexed. Out of scope for this public-site
component (C1); noted so it is not forgotten. Cheapest fix is a robots disallow on the CMS host +/- a
blanket `noindex` meta in the CMS layout — a CMS-side change for a later, separately-scoped task.
---
## 8. Acceptance criteria
- **AC1 — Tags present in prerendered HTML.** `curl`-ing (no JS execution) any public SEO-target page
returns a `<head>` containing title, description, canonical, the full OG block, the Twitter block, and
a JSON-LD script — populated with that page's specifics. (The crawler-visibility guarantee, C2.)
- **AC2 — One component, one line per page.** Each target page invokes `SeoHead` with a single model;
no page hand-writes the tag set. Adding a hypothetical new page type requires a new factory method,
not a new tag block (C3/OCP).
- **AC3 — Release pages carry correct per-medium schema.** A cut → `MusicAlbum` with an ordered `track`
list; a session → `MusicAlbum`/`LiveAlbum`; a mix → `MusicRecording` with ISO-8601 `duration`. Title,
artist, genre, date, cover, and canonical all match the release.
- **AC4 — Graceful partial data.** A release with no description/cover/genre still emits valid head
content (config-default description, config-default or omitted image, omitted optional tags) — no empty
`content=""` tags, no broken JSON-LD (C6).
- **AC5 — Structured data validates.** The emitted JSON-LD passes Google's Rich Results / Schema Markup
Validator for the chosen types (no errors; warnings acceptable). Pure-function builders make this a
unit test plus one manual validator pass.
- **AC6 — Identical output across the double render.** The head content produced by the WASM interactive
pass is byte-identical to the prerender pass for the same page/item (fed from bridged
`PersistentComponentState`, not a fresh fetch) — no client re-render clobbering correct tags (§5).
- **AC7 — Canonical correctness.** Canonical and `og:url` are absolute (config origin + resolved path),
point at the dedicated release route (not an alias/redirect path or a query-string variant), and are
identical to each other.
- **AC8 — 404 is `noindex`.** The not-found page emits `robots: noindex`.
- **AC9 — Zero CMS change.** No file under `DeepDrftManager` is touched (C1).
---
## 9. Wave decomposition
Dependency shape: `22.1 → 22.2 → 22.3`, with `22.4` validating. `22.1` is the cold-start prerequisite.
- **22.1 — Core component + config + model, on the simplest pages (cold-start).** Build `SeoHead`,
`SeoModel` (+ the standard/OG/Twitter tag rendering), and `SeoOptions` (registered via the static
`Startup` seam). Wire the **static pages first** — Home and About — where data is trivially available
at prerender and there is no double-render subtlety beyond the baseline. Proves §5's prerender
emission end to end (AC1) on the easy case. **Depends on OQ1/OQ2/OQ4** (origin, default image, suffix)
— these are config values it needs; can stub with placeholders and swap in Daniel's answers.
- **22.2 — Release detail pages + per-medium JSON-LD (the rich case).** Add the `MusicGroup` /
`MusicAlbum` / `MusicRecording` builders (OQ5 recommendation: typed) and the `ForRelease` factory with
the medium→type mapping; wire `CutDetail`/`SessionDetail`/`MixDetail` from their already-bridged
`ReleaseDto`. This is where AC3/AC5/AC6/AC7 are exercised. **Depends on 22.1** and on OQ5/OQ6.
- **22.3 — Browse + 404 + remaining pages.** `CollectionPage` for browse surfaces; `noindex` for 404;
any remaining public routes. **Depends on 22.1.**
- **22.4 — Validation pass.** Exercise AC1AC9: crawler-view via no-JS fetch (AC1), Rich Results
validator (AC5), double-render-identity check (AC6), canonical/alias matrix (AC7), partial-data
releases (AC4). Largely test/measurement. **Depends on 22.122.3.**
- **(Adjacent, separate) — `robots.txt` + `sitemap.xml`.** Per §7, an endpoint-shaped follow-on, not a
component wave. Tracked as its own unit pending Daniel's call on folding-in vs. separate phase.
---
## 10. Cross-references (read before implementing)
- `DeepDrftPublic/Components/App.razor` — the `<head>` block + `<HeadOutlet @rendermode="InteractiveAuto">`
this component feeds; the place to confirm prerender is not disabled.
- `DeepDrftPublic.Client/CLAUDE.md` — the `PersistentComponentState` prerender-bridge convention, the
`OnParametersSetAsync` same-template-nav rule, the static-`Startup`-called-from-both-`Program.cs`
DI convention (where `SeoOptions` registers), and the presentational-component pattern `SeoHead` follows.
- Auto-memory `tracksview-persistent-state-seam` — why the bridge matters for the double-render identity
(AC6); the SEO model must be built from bridged state, not a fresh client fetch.
- `DeepDrftPublic.Client/Common/ReleaseRoutes.cs` — the canonical-URL source for release pages; the SEO
canonical reuses it (and extends the same "Cut is the default arm" medium-switch discipline).
- `DeepDrftPublic.Client/Pages/{Home,CutDetail,SessionDetail,MixDetail,About}.razor` — the current bare
`<PageTitle>` usage these pages replace; the ViewModels (`ReleaseDto`, `HomeStatsDto`) that feed the model.
- `DeepDrftPublic.Client/Controls/SharePopover.razor` — already builds absolute share URLs from
`ReleaseRoutes`; the canonical/OG URL is the same absolutization, sourced from `SeoOptions.BaseUrl`.
- `DeepDrftModels` (`ReleaseDto`, `TrackDto`, `HomeStatsDto`) — the data the model projects; no new field
needed (C5).
- Google Rich Results / Schema.org `MusicGroup`/`MusicAlbum`/`MusicRecording`/`LiveAlbum` docs — the
validation target (AC5) and the type vocabulary for §3.4.