Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33383cd675 | |||
| 56f7013314 | |||
| 2653e62eeb | |||
| 45bd599bdd | |||
| f976af0f7c | |||
| f3b89ca9d7 | |||
| 8752fc0c98 | |||
| 274d0ace62 | |||
| e3a4364b8c | |||
| 564b704803 | |||
| 6af6677a12 | |||
| 1bdaeaa164 | |||
| a84a99c309 |
@@ -9,7 +9,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
### 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.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.
|
||||
- **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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
**Landed:** 2026-06-20 on dev. Pending: final manual browser/GPU smoke-test on dev.
|
||||
|
||||
@@ -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).
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -70,6 +71,11 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `Common/`: Shared utilities.
|
||||
- `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).
|
||||
- `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`.
|
||||
- `_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.
|
||||
|
||||
**`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
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -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><script type="application/ld+json"></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></script></c> or <c><</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><script type="application/ld+json"></c>
|
||||
/// element. Replacing <c><</c>/<c>></c>/<c>&</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><</c> identically)
|
||||
/// while making <c></script></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;
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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,8 +2,9 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@implements IAsyncDisposable
|
||||
@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.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
@page "/cuts"
|
||||
@using DeepDrftModels.Enums
|
||||
@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.
|
||||
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/archive"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Archive</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits CutDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -17,6 +16,9 @@
|
||||
}
|
||||
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-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||
@@ -37,6 +39,10 @@ else
|
||||
var hasYear = release.ReleaseDate is not 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
|
||||
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. *@
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/"
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForHome(Seo)" />
|
||||
|
||||
@* Hero - split 50/50 *@
|
||||
<section class="hero">
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -16,6 +15,9 @@
|
||||
}
|
||||
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-masthead">
|
||||
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
||||
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||
else
|
||||
{
|
||||
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
|
||||
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/mixes"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inherits MediumBrowseBase
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Mixes</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
|
||||
|
||||
<ReleaseGallery Releases="@Releases"
|
||||
Loading="@Loading"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
@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">
|
||||
Not Found
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@inherits ReleaseDetailBase
|
||||
|
||||
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
|
||||
@inject SeoOptions Seo
|
||||
|
||||
@if (ViewModel.IsLoading)
|
||||
{
|
||||
@@ -20,6 +19,9 @@
|
||||
}
|
||||
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-masthead">
|
||||
<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.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/sessions"
|
||||
@using DeepDrftModels.Enums
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
@inherits MediumBrowseBase
|
||||
@inject SeoOptions Seo
|
||||
|
||||
<PageTitle>DeepDrft Sessions</PageTitle>
|
||||
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
|
||||
|
||||
<ReleaseGallery Releases="@Releases"
|
||||
Loading="@Loading"
|
||||
|
||||
@@ -45,6 +45,16 @@ public static class Startup
|
||||
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@using DeepDrftPublic.Client
|
||||
@using DeepDrftPublic.Client.Common
|
||||
@using DeepDrftPublic.Services
|
||||
@using DeepDrftShared.Client.Components
|
||||
<!DOCTYPE html>
|
||||
@@ -34,11 +35,16 @@
|
||||
@code {
|
||||
|
||||
[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()
|
||||
{
|
||||
base.OnInitialized();
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 AC1–AC10 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.1–18.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). **OQ1–OQ7 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 16–64 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.1–21.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
|
||||
|
||||
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
|
||||
|
||||
@@ -181,6 +181,7 @@ if need_cred "authblocks"; then
|
||||
read -rp " Email host (SMTP server or API host): " EMAIL_HOST
|
||||
read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN
|
||||
echo
|
||||
read -rp " Sender email address (From:, e.g. noreply@${DOMAIN_PUBLIC}): " EMAIL_FROM
|
||||
|
||||
# Admin account
|
||||
echo
|
||||
@@ -201,10 +202,10 @@ if need_cred "authblocks"; then
|
||||
read -rp " Support email address: " SUPPORT_EMAIL
|
||||
|
||||
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
|
||||
)"
|
||||
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
|
||||
else
|
||||
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).** OQ1–OQ7 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 ~1–2 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 ~1–2 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/4–1/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 AC1–AC10 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.1–18.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 117–125) — 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 16–64 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 C1–C7. 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 ~30–60 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 AC1–AC8 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.1–21.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 AC1–AC9: 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.1–22.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.
|
||||
Reference in New Issue
Block a user