Files
deepdrft/PLAN.md
T
2026-06-23 06:21:52 -04:00

76 KiB
Raw Blame History

PLAN.md — DeepDrftHome forward roadmap

Forward-looking roadmap. Sits alongside CONTEXT.md (architecture orientation) and COMPLETED.md (history). Per CONTEXT.md §6, items move from here to COMPLETED.md when work lands; do not delete completed entries.

Organised by theme, not by date. Themes are roughly ordered by current product weight, not commitment. Nothing here carries a timeline unless it explicitly says so.


0. Baseline — what just landed

A two-part audit (design + streaming) ran on 2026-05-17 and the fixes for Critical, Major, and Minor findings are now on dev. The remainder of this plan assumes that baseline. In summary the audit-pass fixed:

  • Index concurrencyVaultIndexDirectory no longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers.
  • Repository semanticsTrackRepository.Update now fails-fast when an Id is not found instead of silently issuing an INSERT.
  • Streaming Criticals — concurrent-seek race in the client, dirty trailing bytes leaking out of the ArrayPool-rented buffer, final-tail audio dropped at EOF below the minimum decode frame, and the assumption that the first network chunk contains the whole WAV header.
  • 17 design and streaming Majors/Minors across all eight projects — format-validation alignment between processor/offset/decoder, IAsyncDisposable on the player provider, cancellation tokens threaded through the HTTP path, structured logging into the FileDatabase subsystem, sort-sentinel cleanup, sundry DRY/SRP tightenings.

What this means for the roadmap: the streaming substrate is solid. Future work can build on top of it rather than around it. The remaining items in TODO-V2.md that did not land are deferred as features, not bugs — they are captured below under Phase 1.


Phase 1 — Streaming features deferred from the audit

These were flagged during the audit but classified as feature work, not defect fixes. They are listed in rough order of user-visible impact.

1.3 Preload / prefetch of the next track

Split as of 2026-06-15. This item bundled two things: (a) a queue model ("a notion of next track") and (b) preload/prefetch (begin the next track's bytes during the current tail). The queue half (a) is now absorbed into Phase 11 (commitment 7 — Daniel: "now is the natural time for that"; full spec in product-notes/phase-11-public-site-enhancements.md §3c). The preload half (b) remains deferred here and still gates crossfade (1.4) and gapless (1.5). The open question below — queue in IPlayerService vs. a separate orchestrator — is answered in the Phase 11 spec (strong steer: a separate IQueueService above the single-slot player; final call staff-engineer's at implementation). When Phase 11's queue lands, the preload below becomes "add a subscriber to the queue's already-known next track," not a fresh queue design.

  • What (deferred — preload only): No mechanism to begin the next track's stream during the tail of the current. Each play is a cold fetch.
  • Why it matters: Prerequisite for both crossfade (1.4) and gapless (1.5). Also a perceived-latency win on its own — track-change feels instant when the bytes are already in flight.
  • Shape: A second HttpClient request kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a staged StreamDecoder instance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. The "next track" it prefetches comes from Phase 11's IQueueService — that dependency is now satisfied by the queue work, not an open question.

1.4 Crossfade

  • What: Smooth A→B transition with overlapping fade-out / fade-in.
  • Why it matters: DJ/mix aesthetic that fits the DeepDrft collective's electronic-music context. Distinguishing UX from generic "next track."
  • Shape: Architecturally two simultaneous PlaybackScheduler instances suffice — each owns its own gain node, crossfaded via GainNode.gain.linearRampToValueAtTime. The wiring is the work, not the audio graph itself.
  • Prerequisite: 1.3 (Preload) — there is nothing to fade into without prefetch.

1.5 Gapless playback

  • What: Eliminate the inter-track silence that exists today.
  • Why it matters: Important for live-set rips, mix tapes, anything authored to flow continuously.
  • Shape: The decoder must be able to start the next track's first buffer scheduled exactly at the end of the current one's last buffer (sample-accurate, not wall-clock). With PlaybackScheduler's existing 500 ms lookahead this is mechanically achievable — the next track's first AudioBufferSourceNode.start(t) is set to the previous track's end time.
  • Prerequisite: 1.3 (Preload). Also needs to play nicely with 1.2 because gapless across formats is hard (encoder padding/priming on MP3 in particular).
  • Constraint: Truly sample-accurate gapless requires knowing the priming/padding sample counts of the source format. Out of scope for WAV-only; revisit when format diversity lands.

1.6 Track-skip on error

  • What: A failed processStreamingChunk aborts the entire load with no recovery path.
  • Why it matters: One corrupt frame at byte 4M of a 100 MB stream currently means the listener loses the entire track. Should at minimum surface a clear error and (optionally) skip past the bad region.
  • Shape: Two-level response.
    • Cheap: catch in the streaming loop, surface a user-visible error, advance the gallery to the next track if a queue exists.
    • Richer: byte-scan forward to the next valid frame header for the format and resume. Format-dependent — only worth doing once 1.2 lands.

1.7 Safari compatibility

  • What: Two known Safari edge cases.
    • webkitAudioContext.close() is async-but-not-Promise on older Safari (≤ ~14); await resolves immediately and the next initialize() can run against a not-yet-closed context.
    • iOS Safari < 15 had streaming-fetch quirks; HttpCompletionOption.ResponseHeadersRead behaviour is not guaranteed there.
  • Why it matters: Real listener share. iOS in particular is a primary listening surface for music.
  • Shape: For the close() race — detect webkitAudioContext and poll state === "closed" with a short timeout instead of trusting the await. For the fetch quirks — first decide the minimum supported iOS version; if pre-15 is in scope, fall back to a non-streaming fetch path and accept the latency.
  • Open question: What's the floor? Decide before designing the fallback. iOS 15+ as the floor would let us drop the second concern entirely.

These follow from CONTEXT.md §5. Direction is strongly implied but no specific UI has been committed.


Phase 6 — CMS Enhancements (Completed)

See COMPLETED.md for Phase 6 (§6.1, §6.3) and entity-prep (§6.2 model layer) which landed on dev in June 2026.


6.2 Card-contextual filtering of the Tracks page — [superseded by §8]

  • What: Make the Album and Genre dashboard cards navigate into a filtered /tracks view (e.g. clicking an album card shows only that album's tracks), rather than the unfiltered table.
  • Why: Turns the dashboard from a read-only summary into a navigation hub — the natural next step once the cards exist.
  • Why deferred: The dashboard cards aggregate across all albums/genres — there is no single album/genre to filter to from a top-level count card. Meaningful per-album/per-genre navigation needs an intermediate browse surface (a list of albums, a list of genres) for the admin to pick from — i.e. it's really a CMS analogue of the public AlbumsView/GenresView, not a property of the summary cards. That's a larger surface than the dashboard itself and shouldn't be smuggled in. The GET api/track/page endpoint already accepts album= and genre= query filters, so the API substrate is ready; the missing piece is the CMS browse UI and the filter plumbing in TrackList.
  • Superseded: §8 (CMS Track Browser) builds exactly the intermediate browse surface this item was waiting on — Album Mode and Genre Mode are the CMS analogue of AlbumsView/GenresView, and the filter plumbing into GetPagedAsync is part of §8's data contract. This item folds into §8; do not implement it separately.

Phase 3 — New content kinds

3.1 Live / session content

  • What: The home page advertises "Live Sessions" and "Video Content (coming soon)". No data model exists for these.
  • Why it matters: Honour the home page copy. Also differentiates the site from a generic track gallery — live sessions and video are the collective's authored output.
  • Shape: Speculative; no commitment yet.
    • Likely new entity table(s) sibling to TrackEntity (SessionEntity, VideoEntity?) — or a polymorphic MediaEntity with discriminator. The choice affects how much code in TrackService / TrackController can be reused.
    • New vault type(s). MediaVaultType.Media exists and is the obvious home for video; sessions are probably still Audio.
    • New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
  • Prerequisite: Probably 2.1 (vault wiring proof) and a decision on the entity model before any code lands.
  • [speculative] — direction inferred from home-page copy, not a Daniel-confirmed commitment.

Phase 4 — Infrastructure / delivery

4.3 Dual-write rollback / dead-letter log

  • What: If content-side write succeeds and SQL-side write fails, audio is orphaned in the vault. No compensating mechanism exists.
  • Why it matters: A latent data-integrity issue. Materially riskier once web upload (2.4) exists.
  • Shape: Audit suggested a DeadLetterLog recording orphaned entryKeys for a periodic maintenance pass. Lighter than full transactional rollback (which the dual-database split fundamentally cannot give us).
  • Prerequisite: None. Worth landing alongside or just before 2.4.

Phase 5 — Documentation backlog

5.1 Folder-level CLAUDE.md sweep

  • What: Eight folder-level CLAUDE.md files need writing/rewriting per the brief in DOC_PLAN.md. Five are rewrites (drift from the .NET 10 upgrade and structural moves); three are new (DeepDrftWeb.Services, DeepDrftContent.Services — the two libraries where most domain logic now lives — plus the open question on DeepDrftContent.Services/FileDatabase/README.md).
  • Why it matters: The agent guidance files are how every future implementer (human or agent) gets oriented in a directory. They are currently misleading in ways that will cause wrong assumptions on first contact — claiming .NET 9, referencing MediaPath that has been EntryKey for two migrations, describing a FileDatabase/ tree inside DeepDrftContent that has moved out, and missing entirely for the two *.Services libraries.
  • Shape: Doc-keeper executes against DOC_PLAN.md. Order of operations and the per-folder briefs are already specified there.
  • Prerequisite: None. Can run fully in parallel with any feature work.
  • Constraint: Wait on Daniel for the DeepDrftContent.Services/FileDatabase/README.md judgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.

Phase 7 — Shared UI Components

Reusable presentational components in DeepDrftShared.Client (the RCL consumed by both the public site and the CMS). Distinct from the player stack and CMS surfaces — these are host-agnostic building blocks both apps compose.


Phase 8 — CMS Track Browser

Three browse modes for the CMS /tracks page — Track, Album, Genre — selected by a toggle, each deep-linkable so the public home page can link straight into a mode. One view-model (DI-scoped, matching the TracksViewModel pattern) feeds all three views; the divergence is in rendering, not data paths (per the standing "same data, different uses" preference). This supersedes the deferred §6.2 — Album and Genre modes are the intermediate browse surface that item was waiting on. Full spec: product-notes/phase-8-cms-track-browser.md (normalization gate, component decomposition, VM design, URL scheme, data contracts, open questions).

§8.0 landed on 2026-06-11 — a breaking TrackEntity normalization has been completed and is stable on dev. §8.1–§8.5 are now unblocked. The Waveform Pre-Processing tab is removed, folded into an in-grid status column + per-row/page-level generate actions (see §8.2).


A small set of items that are real but don't fit a phase yet. Surface them when they become relevant rather than committing now.

  • Identity / accounts. Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. [speculative] until Daniel signals interest.
  • ITrackService interface. Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.
  • Test coverage outside FileDatabase. Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 14 land, test scope should expand — at minimum WavOffsetService, AudioProcessor, TrackService (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.
    • Real-Postgres integration harness (deferred per Daniel — big lift). The Phase 16 distinct-listener aggregation LINQ (EventRepository.CountDistinctListenersAsync / ...ForTrackAsync / ...ForReleaseAsync) is currently exercised only against the EF in-memory provider, which does not validate real Npgsql SQL translation — the distinct-count queries want translation verification against an actual Postgres instance. More broadly, an integration-test harness against a real Postgres is the deferred prerequisite for trusting any non-trivial LINQ across the EF surface. Explicitly deferred by Daniel (big lift); a note for now, not committed work — no timeline.

Phase 9 — Release Medium Types

Releases gain a top-level medium discriminator above the existing ReleaseType. Three media: Studio CUTS (Cut — the only medium that uses Single/EP/Album), Live SESSIONS (Session — a single live track with a distinct hero image), DJ MIXES (Mix — a single long track with a preprocessed high-resolution waveform datum). This touches the data model, the API, the CMS, and the public site.

The public home page already carries the three-medium framing as editorial cards (Studio / Live / DJ Mix — COMPLETED.md §8.6, landed 2026-06-12), but those cards have no destinations and nothing below the copy layer knows what a medium is. Phase 9 makes the medium real and gives those cards somewhere to point.

Architectural spine — discriminator enum + optional metadata table. ReleaseMedium is a plain enum column on ReleaseEntity. A medium that needs data beyond the base release (Session's hero image, Mix's waveform datum) gets its own 1:1 metadata table; a medium that needs nothing extra (Cut) is the base ReleaseEntity. This is Open/Closed at the schema level — a future medium (e.g. Video, §3.1) adds an enum value and optionally one metadata table, and changes zero existing tables. The alternatives (one wide nullable table; an EF type hierarchy) both collapse to the god-table the Phase 8 normalization moved away from — rejected. Full design, contracts, and the SOLID rationale: product-notes/phase-9-release-medium-types.md.

Design discipline throughout: extension, not modification. Where a per-medium mapping is unavoidable (card → browser, medium → API projection, medium → detail hero), keep it in one table per concern — never a scattered three-arm switch. Drive CMS cards and nav sub-items off Enum.GetValues<ReleaseMedium>() + a display-metadata lookup, so a new medium surfaces automatically.

The ReleaseType-only-for-Cut invariant. Single/EP/Album is meaningful only when Medium == Cut. Enforce as a domain rule (service layer ignores/resets ReleaseType for non-Cut; CMS hides the field unless Cut; ReleaseDto.ReleaseType is nullable, nulled at the single entity→DTO mapping point for non-Cut so one producer enforces and no consumer needs the rule), not a DB constraint — by choice, not necessity: EF Core supports check constraints first-class (HasCheckConstraint, versioned in migrations, Npgsql-supported), but the invariant is advisory ("meaningless," not "invalid") and the read model enforces it at one point. The column stays on ReleaseEntity as a named exception to the metadata-table pattern: a CutMetadata table was considered and rejected because the /cuts hot path reads ReleaseType on every card and Phase 8 §8.0 just landed the column (see spec §1). Future media must not copy this — the default remains the metadata table.

Sequenced as four waves. Wave 1 is a prerequisite for everything; within Waves 24 the lettered tracks are parallel.

Dependency summary: 1 → 2 → 3 → 4. Wave 4 (public site) can begin once Wave 2's api/release family is stable; both Wave 4 build and acceptance are independent of Wave 3 (CMS) — the body-less POST api/release/{id}/mix/waveform trigger (9.2.B) can seed real waveform datum for acceptance testing without any CMS in existence, and hero images seed via a script against 9.2.B likewise.

Waves 17 are landed (COMPLETED.md §9). Wave 6 closes two functional gaps a post-landing smoke-test survey surfaced — surfaces the medium taxonomy did not reach, not regressions. Wave 7 hardens the single-track-per-medium rule from a CMS-form convention into a real domain invariant — the one place the medium taxonomy is declared but not enforced below the UI.

9.8 Wave 8 — Remediation (fully landed; all tracks complete)

Daniel tested the landed Phase 9 surface end-to-end and produced a punch-list of corrections before the phase is called complete. These are not new features — they are the gap between what the Wave 17 specs built and what hands-on use wants. The theme is the same one Phase 9 has carried throughout: the medium taxonomy reaching every surface it should, and the browse surfaces matching the mental model rather than the implementation's first cut.

Two surfaces dominated: the CMS Release Archive (the card-grid landing is the wrong shape — Daniel wants medium tabs, not navigate-away cards) and the public Archive (the three-card overview is dead weight; the searchable all-releases view is the archive — release-cardinal, decided). The Mix Visualizer redesign (8.K) was pulled out of Phase-9-completion scope and ran as a post-Phase-9 wave from a finished spec (product-notes/phase-9-mix-visualizer-redesign.md); it has now also landed.

Open questions resolved (Daniel, 2026-06-13): 8.H is decided H2 (a new release-cardinal searchable browser at /archive; cascade: /tracks demoted from nav, route kept; mobile ARCHIVE → the browser; three-card overview fully retired); 8.I drops GENRES from the nav only (route kept); 8.F makes the Session hero optional-but-warn-if-missing; 8.E defaults the ALL-tab Add Track to Cut with the medium selector staying user-changeable. A new track 8.L consolidates the release-name/track-name pair into a single name for single-track media (derived track name kept synced, decided), and 8.M (split off 8.L) retires the legacy TrackNew/TrackEdit forms by folding them into the batch forms to reduce code surface.

Full track decomposition, acceptance criteria, and parallel/dependent analysis: product-notes/phase-9-wave-8-remediation.md.

Dependency shape: 8.B is the foundation for the CMS tab work (8.A consumes the shared grid; 8.C/8.E layer on once 8.A lands). 8.L follows 8.G and coordinates with 8.E/8.F (same forms). 8.M (legacy-form retirement) follows 8.L and is architectural (route map + addressing decision). On the public side, 8.H (decided H2 — the new release-cardinal archive) gates 8.I. All Wave 8 tracks are landed — Phase-9-completion gate (8.A8.J + 8.L), 8.M, and the post-Phase-9 8.K Mix Visualizer redesign. Landed tracks: 8.A, 8.B, 8.C, 8.D, 8.E, 8.F, 8.G, 8.H, 8.I, 8.J, 8.L (2026-06-13); 8.M (2026-06-14); 8.K (2026-06-14).

Phase 10 — Mix Visualizer WebGL2 Renderer

The landed Canvas 2D Mix visualizer (8.K) renders at 12 FPS and cannot afford the planned effects — a staff-engineer analysis found the per-frame killers (full-viewport shadowBlur, CSS backdrop-filter, per-frame getBoundingClientRect) structural to the approach, and the planned effects (bulge, lava-lamp detach, a morphing 2D color field, glass) are all per-pixel/per-frame work — exactly what Canvas 2D is worst at and a fragment shader is best at.

Decision (Daniel, explicit): rebuild as a WebGL2 fragment-shader renderer. No Canvas 2D stopgap — "WebGL as step 1, no pussyfooting." This supersedes 8.K §E's Canvas-2D-default recommendation; the "industry-standard, well-commented, no tricks" discipline carries forward as textbook WebGL2 with a commented shader. Target a smooth 60 FPS. Strictly read-only (no playback-control changes); the duration-derived ~333 samples/sec datum (8.K §F) and the existing Blazor↔JS bridge are both preserved — the datum now lands as a GPU texture rather than a CPU-walked array.

Adds a controls row above the mix details / below the back button: four continuous, session-persistent sliders — resolution (relocated 8.K zoom), bubblyness (box→liquid bulge), detach ("unleash the lava lamp" — blobs pinch off and rise), color-shift speed (gradient morph rate). The headline visual is a living 2D navy↔moss gradient field (theme tokens from DeepDrftPalettes) that varies per-bar and shifts along time, never static; plus an in-shader glass treatment (specular/Fresnel/frosted/refraction — no CPU backdrop-filter). Persistence mirrors MixVisualizerZoomState (widen to a MixVisualizerControlState holding all four).

Full design, renderer architecture, the four effects, acceptance criteria, and phasing: product-notes/mix-visualizer-webgl-renderer.md.

Sequenced as four waves. Wave 1 (renderer swap at parity — prove WebGL2 on screen at 60 FPS, bridge intact, no new effects) is the load-bearing prerequisite. Wave 2 (controls row + widened state) and Wave 3 (the four effects in the shader) both follow Wave 1; the four effects within Wave 3 are independently shippable and tunable. Deferred (Daniel): control-range guards and motion-speed coupling to bubblyness — he tunes bad ranges by hand once on screen. Landed: Wave 1 (2026-06-15). Wave 2 (2026-06-15). Wave 3 (2026-06-15).

Wave 4 — detail-page polish + controls rework (presentation only; the final wave). A UI/placement pass over the Mix detail page — no renderer, state, bridge, or mapping change. (1) The four controls move out of the always-visible row into a popover (MudPopover, SharePopover-idiom) opened by a new bespoke lava-lamp icon button anchored top-right of the body, across from the ← Back link (recommend a new TopRightAction slot on ReleaseDetailScaffold, laid as a SpaceBetween row with the back link). (2) The lava-lamp SVG lives in DeepDrftShared.Client/Common/DDIcons.cs in the hand-rolled gas-lamp style (currentColor, 24×24 viewBox, raw-string const) — a recognizable lamp with two-three suspended blobs. (3) The four MudSliders become four RadialKnobs (DeepDrftShared.Client/Components/RadialKnob.razor) in a row in the popover, each carrying its existing Material icon (ZoomIn/BubbleChart/Air/Palette) as an adjacent MudIcon caption — RadialKnob has no icon slot (its Label is SVG text), so icons sit beside each knob. Knobs bind Value/ValueChanged to the unchanged MixVisualizerControlState via the same OnXChanged handlers + NotifyChanged() seam the sliders use today (resolution via MixZoomMapping fraction; other three normalized [0,1]; HoldValue=false for live feel). (4) Widen the Mix body to match the Sessions detail page — MudContainer MaxWidth="Large" (~1280px, up from the scaffold's 760px), Mix-scoped so Track detail is unaffected. Depends on Wave 3 merged (the knobs drive the Wave 3 effects) and supersedes the controls-row design (product-notes/mix-visualizer-webgl-renderer.md §3 → §7). Read-only contract intact; no knob is a seek surface. Full design + acceptance: that spec's §7.

Phase 10 — Reframe (Lava): Waves R1R4

Landed: 2026-06-17 on dev. See COMPLETED.md for the full completion record.


Phase 11 — Public Site Enhancements

The next pass over the public listening surface, after Phase 9 + Wave 8 moved the site to release-cardinal browse (/archive) and per-medium detail. The spine of the phase: make the release the cardinal unit of the public site, make every navigation an addressable shareable URL, and make the album a first-class playable object (ordered, queue-able, shareable). Nine Daniel commitments (the original four, plus four added 2026-06-15 when he resolved the open questions and expanded scope, plus a ninth added 2026-06-16): (1) a Cuts detail page /cuts/{id}; (2) the player-bar release-title resolves medium → dedicated detail page; (3) retire the whole track-cardinal stack and normalize release-card rendering into shared components; (4) encode Archive filters in the URL; (5) explicit track ordinal editable from the CMS; (6) release-level Share; (7) a play-queue system (absorbs the queue half of §1.3); (8) a release Description field — multiline free-text on the base release (all media), edited from the CMS add/edit forms and rendered as a text block on every detail page; (9) release GUID identifiers — front the release's transparent sequential int PK with an opaque app-minted GUID handle (the track-EntryKey model), swept across every public addressing site. Full design, framing corrections, wave decomposition, gap analysis: product-notes/phase-11-public-site-enhancements.md.

State it inherits (verified 2026-06-15). /sessions/{id} and /mixes/{id} detail pages exist and are mature (both inherit ReleaseDetailBase's prerender bridge; MixDetail composes ReleaseDetailScaffold, SessionDetail deliberately diverges). /archive is already a release-cardinal searchable browser (search + medium + genre). ReleaseGallery is the shared release-card grid — but only Sessions/Mixes use it; Archive and Cuts re-implement equivalent card markup inline. The real gaps: Cuts have no single-release detail page (/cuts cards open /tracks?album={title}), and /archive holds its filters in component fields, not the URL. The queue/playlist does not exist (single-slot player).

Headline correction — commitment 5 is already built. The brief framed the track ordinal as a new column + EF migration + a Daniel-gated apply step. The read shows it already shipped in Phase 8: TrackEntity.TrackNumber (1-based, non-null), migration 20260611005700 already applied, TrackDto mirror, API write path (validated > 0), CMS reorder (BatchEdit assigns ordinal from list position on submit), and the read already .OrderBy(t => t.TrackNumber). No new schema, no migration to gate. Commitment 5 collapses to verify-and-consume: confirm the public read projects/sorts TrackNumber and that CutDetailViewModel orders by it (a one-line fix if not). See spec §3a.

Mirror-image on commitment 8 — the Description column is genuinely new. Where commitment 5 turned out already-built, commitment 8 is the opposite: no Description member exists on ReleaseEntity or ReleaseDto (greps return nothing; the entity carries Title/Artist/Genre/ReleaseDate/ImagePath/ReleaseType/Medium + the two metadata satellites). So commitment 8 is the real cross-stack schema project — just for a different field: a new base-ReleaseEntity.Description column + EF migration (Daniel-gated apply), ReleaseDto mirror, TrackConverter round-trip, write-path plumbing (UpdateTrackMetadataRequest + upload form + the unified services — threaded wherever Genre is, since there is no dedicated release-update endpoint; release-cardinal fields ride the track update/upload path), the CMS AlbumHeaderFields multiline input, and the detail-page text block. It is a base field (uniform across media) so it lives on the base release, not a per-medium satellite (Phase 9 spine). See spec §3d.

Framing corrections (brief vocabulary vs. live routes). (1) There is no /tracks/{id} route — the track-cardinal detail is /track/{EntryKey}. The brief's "/tracks/{id} becomes a router" is best realized as a medium→route resolver at click sites (the player bar already carries release id + medium — no round-trip), plus a thin /tracks/{id} redirect page for deep links. (2) The new /cuts/{id} album page is the phase's center of gravity — the first multi-track release detail. (3) Requirement 4 is a URL-binding pass over the existing ArchiveView, borrowing the TracksView [SupplyParameterFromQuery] pattern — not a new browser.

Design discipline. The medium→route resolver is one table (ReleaseRoutes.DetailHref) consumed by the player bar, Archive, and Cuts cards. The shared ReleaseGallery becomes the one release-card grid across all four browse surfaces (Archive/Cuts fold in via a new per-card HrefResolver), not three inline copies (memory One source, multiple views). The /cuts/{id} page composes ReleaseDetailScaffold via a generalized Header slot + a BodyContent slot for the track list — not a boolean layout flag (Phase 9 §5.3). The queue is a separate IQueueService orchestrating above the single-slot player (strong steer; final call staff-engineer's). Header Play binds to a single handler that swaps single-track → QueueService.PlayRelease with no page change (memory Design for adaptability up front).

Sequenced as eight waves; the critical path is 11.A → 11.B → 11.C → 11.H, with 11.D / 11.E / 11.F / 11.G hanging off the front and 11.H sitting at the tail (it re-types the public addressing surface that 11.B11.E build on).

  • 11.A — /cuts/{id} album-detail page. Left header (name, artist, genre, year, Play + Share), right cover with theme border, ordered track list (by TrackNumber) with per-row play, header Play. New CutDetailViewModel; reuses GetById + the releaseId-filtered track page (both exist). Ordinal is a verification (§3a), not a dependency. Header/row Play consume 11.F when present, else degrade to single-track (§3.4 seam). Load-bearing prerequisite for 11.B's Cut resolution.
  • 11.B — ReleaseRoutes resolver + repoint. Promote ArchiveView.DetailHref to a shared ReleaseRoutes.DetailHref; Cut resolves to /cuts/{id} (needs 11.A); repoint player-bar title (→ release), Archive cards, AlbumsView cards; thin /tracks/{id} redirect page. Depends on 11.A.
  • 11.C — retire + normalize (the heart). With §2 removing every inbound link: delete the whole track-cardinal stack (TracksView/TrackDetail/TrackCard/TracksGallery/GalleryViewMode + /tracks, /track/{EntryKey} routes) and fold Archive + Cuts inline cards into the shared ReleaseGallery (new HrefResolver); consolidate the medium-label lookup. Depends on 11.B. (Cut track-row is a separate small TrackRow, not ReleaseGallery.)
  • 11.D — Archive filters in the URL. /archive?q=&medium=&genre=, history-driven (§5). Touches only ArchiveView. Free-floating — but coordinate with 11.C (both edit ArchiveView).
  • 11.E — release-level Share. SharePopover gains a release mode that copies ReleaseRoutes.DetailHref(release); wire the Cut header Share to it. Depends on 11.B (resolver) + a release detail to share.
  • 11.F — queue model. IQueueService above the single-slot player + one new player TrackEnded hook + player-bar skip controls. Free-floating, can start cold day one. Gates the Cuts "play album" affordance (11.A header Play). Preload (§1.3 half b) stays OUT — design the seam, defer the feature.
  • 11.G — release Description schema slice. New ReleaseEntity.Description column + EF migration (Daniel-gated apply), ReleaseDto mirror, TrackConverter round-trip, write-path plumbing (UpdateTrackMetadataRequest + upload form + the unified services, threaded wherever Genre is), CMS AlbumHeaderFields multiline input (§3d). Free-floating, can start cold day one — the only gate is Daniel's migration go-ahead. The detail-page render is NOT in this wave: the Cut text block rides 11.A, the Session/Mix block is a small additive touch to those existing pages. Both degrade cleanly (null Description renders nothing), so render & schema can land in either order.
  • 11.H — release GUID identifiers (terminal public-site wave). Front the release long PK with an app-minted GUID-string EntryKey column — the same pattern tracks use (TrackEntity.EntryKey is required string, app-minted Guid.NewGuid().ToString(), keeping the int PK private). New ReleaseEntity.EntryKey (string, unique index, minted at FindOrCreateRelease) + EF migration that backfills a GUID-string EntryKey for every existing release row at migration time (Daniel-gated apply); ReleaseDto.EntryKey; TrackConverter round-trip; re-type the public addressing surface from long to the EntryKey handle — detail routes (:long{EntryKey}), the /tracks/{id} redirect, ReleaseRoutes.DetailHref, SharePopover.ReleaseId, the public read path, and the public release API params (GET api/release/{id} + the releaseId track-page query). Internal FKs (track→release, satellite→release), the long int PK (unused by the app), and the ApiKey-gated CMS endpoints stay on the int. Depends on 11.B (landed), 11.C, 11.D, 11.E — it sweeps the routes/resolver/share/cards those waves create or edit, so it is the last public-site wave (spec §3e.7). Gating decision (Daniel, spec §3e.5(1)) — RESOLVED (additive EntryKey, track-pattern): additive app-level GUID-string EntryKey column matching tracks; the long PK stays DB-only and unused by the app; existing rows are backfilled at migration time (not a dev reset). Daniel's rationale (2026-06-16): "long at the DB level with an app-level guid EntryKey for the releases just like tracks; PK is not used by the app; migrate the existing data to provide the entry key at migration time." The true PK retype is declined (framework fork of Cerebellum.BlazorBlocks.ModelsBaseEntity.Id hardwired long — plus full FK rewrite; recorded as considered-and-declined per file convention). Still open: raw-GUID URL (recommended) vs. slug, and migration ordering after 11.G's snapshot.

Landed: 11.A (2026-06-16); 11.F (2026-06-16); 11.G (2026-06-16); 11.B (2026-06-16); 11.C (2026-06-16); 11.E (2026-06-16); 11.D (2026-06-16); 11.H (2026-06-16). The §3.4 PlayAlbum→IQueueService seam (deferred in 11.A, awaiting 11.F) is now closed: CutDetail.razor consumes the cascaded IQueueService — header Play calls Queue.PlayRelease(ViewModel.Tracks, 0), per-row play calls Queue.PlayRelease(ViewModel.Tracks, index), currently-playing row toggles play/pause, null-safe fallback to SelectTrackStreaming when the queue cascade is absent (2026-06-16). All Phase 11 tracks (11.A11.H) are now landed; Phase 11 is complete. Two release-table migrations are authored but not yet applied (Daniel-gated, apply in author order): 20260616035252_AddReleaseDescription (11.G) then 20260616210143_AddReleaseEntryKey (11.H).

Dependency shape: 11.A → 11.B → 11.C → 11.H; 11.B → 11.E; 11.D, 11.F, 11.G parallel (11.D coordinates with 11.C on ArchiveView; 11.F's "play album" is consumed by 11.A; 11.G's Description render rides 11.A + a Session/Mix touch, degrading on null). 11.H is terminal — it re-types the public release-addressing surface (routes, ReleaseRoutes, SharePopover, cards, public API params) that 11.B11.E create/edit, so it follows all of them; its migration is authored after 11.G's so the EF snapshot stays linear. The cold-start items are 11.A, 11.F, and 11.G — kick 11.A + 11.F off first so "play album" works on first ship of the Cut page; 11.G runs alongside on its own track; 11.H waits for the addressing surface to settle.

Resolved by Daniel (2026-06-15), kept visible per file convention: player-bar title → release detail (was OQ1); track ordinal in scope and already built (was OQ4, reversed then found done); retire the whole track-cardinal stack (was OQ5, full cut chosen); release-level Share in scope; play-queue in scope (queue half of §1.3 absorbed; preload half stays deferred); release Description field in scope (commitment 8 — a real new column, lands as schema slice 11.G with the render on 11.A + a Session/Mix touch). Still open (spec §7.2): /cuts/{id} scaffold strategy (generalized Header slot — recommended — vs. bespoke); Cut header affordance idiom (icon vs. labeled buttons); queue architecture (separate IQueueService — strong steer; staff-engineer's final call); whether release-share keeps "Embed player" (recommend copy-link-only); Description render plain-text vs. markdown (recommend plain text + preserved line breaks for v1) and column max-length (recommend 20004000); /genres fate (out of scope, flag as adjacent).


Phase 16 — Anonymous Play & Share Tracking

The phase deferred behind the home-hero Plays stat card (NowPlayingStats.razor's third card, today a static XXX / Plays (Coming Soon) odometer placeholder). Adds a privacy-light, anonymous telemetry layer to the public site: counting plays (bucketed by completion) and shares, tied to individual tracks and releases, plus an optional unique-listener "plus" metric. Hard constraint: no accounts, no PII, anonymous identification only — the unique-listener metric in particular is solved within that constraint, not around it. Full design, metric definitions, instrumentation seam, anonymity-mechanism options, storage model, the card payoff, and wave decomposition: product-notes/phase-16-play-share-tracking.md.

Architectural spine. Plays are instrumented at one seam — the StreamingAudioPlayerService playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the real share surface (SharePopover's Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship fire-and-forget via sendBeacon to new POST api/event/{play,share} endpoints (proxied through DeepDrftPublic, same hop as api/track/*), land in an append-only SQL event log + incremental counter rollup in DeepDrftData, and the home card reads the total through the existing GET api/stats/home / HomeStatsDto / IStatsDataService path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is resolved server-side from the track→release join (client sends only the track key); release plays are derived (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved.

Completion buckets (settled). partial < 30%, sampled 3080%, complete > 80% — exhaustive, non-overlapping; headline plays = sum of all three (D1 resolved). The engagement floor (D2) drops trivial skips: a listen counts only at ≥3s OR ≥5% of duration, whichever smaller.

Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST. Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: 16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last.

  • 16.1 — Foundation: capture seam + transport + event log (nothing reads it yet). Player-service play tracker (high-water mark + engagement floor), share tracker in SharePopover (debounced), sendBeacon interop + unload handler, POST api/event/{play,share} (proxied, rate-limited), append-only play_event/share_event log + incremental play_counter rollup, server-side release resolution + derived release totals. No anonId yet; no card consumption. Cold-start wave. Landed: 2026-06-19 on dev. Migration 20260619155610_AddPlayShareTelemetry authored but not applied (Daniel-gated).
  • 16.2 — Completion-bucket classification + shares. Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). Depends on 16.1. Landed: absorbed into 16.1 — all §4.1 deliverables shipped inside the wave 16.1 foundation: PlayBucket enum (Partial/Sampled/Complete, exhaustive/non-overlapping) wired tracker → payload → log → per-bucket counter columns (PlayCounter.PartialCount/SampledCount/CompleteCount, TotalPlays computed sum); ShareChannel enum (Link/Embed) on ShareEvent.Channel; API-boundary bucket validation. The only §4.2 item not built is the optional share_count rollup on play_counter — correctly deferred (shares are not on the home-card hot path; per-target reads are speculative wave 16.4).
  • 16.3 — Unique-listener anonId layer (lowest-priority metric, D5). Option A: mint/read client first-party localStorage id, thread anonId onto payloads (nullable in the log), count distinct server-side (all-time, D3). The last metric layer — folded into "everything finished," explicitly last-built of the substrate. Depends on 16.1; builds on 16.2. Landed: 2026-06-19 on dev (merge 297805b). No migration — anon_id varchar(64) columns and IX_play_event_anon_id/IX_share_event_anon_id indexes already shipped in the 16.1 migration.
  • 16.4 — Per-target / CMS stats surfaces. [speculative — not built; passed over]GET api/stats/{track,release}/{key} + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. Skipped as speculative per Daniel (2026-06-19); available to build later if a surface wants it. Off the critical path to the card. Depends on 16.116.3.
  • 16.5 — Home Plays-card payoff (CAPSTONE, built LAST). Extend HomeStatsDto + GetHomeStatsAsync with TotalPlays (+ secondary line = unique listeners, D7); flip NowPlayingStats's third card from placeholder to live through the existing GET api/stats/home round-trip. The final wave — built only once 16.116.3 are in place. Landed: 2026-06-19 on dev.

Phase 16 is complete. Waves 16.1, 16.2 (absorbed into 16.1), 16.3, and 16.5 all landed on dev (2026-06-19). Wave 16.4 was speculative and deliberately skipped. Privacy footer line (DeepDrftFooter.razor .deepdrft-footer-privacy disclosure) also landed. The anonymous play/share substrate, unique-listener metric, and live Plays card are in place.

Product decisions D1D7 — RESOLVED (Daniel 2026-06-19, spec §10). D1 (three-bucket sampled), D2 (engagement floor on, ≥3s/≥5%), D4 (release plays derived server-side), D5 (Option A — client-minted first-party localStorage id, metric labelled "listeners," fingerprinting rejected) resolved on explicit pick. D3 (all-time window), D6 (incremental-on-write rollup), D7 (card secondary = unique listeners) resolved-by-default — low-risk to revisit during their wave. Road-not-taken preserved in the spec.

Adjacency to deferred Identity / accounts (the un-phased backlog item above). This phase is the deliberate anonymous answer to "how many plays" — it does not need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement.


Phase 17 — Player-Bar Queue View

Phase 11 (wave 11.F) built the queue engine (IQueueService/QueueService — ordered playback, auto-advance, skip-prev/next, armed-idle release embeds) but gave it no UI. A listener can start an album and skip through it, but cannot see what is next, reorder it, drop a track, or hand-build a queue. Phase 17 surfaces the queue as a first-class, visible, editable object in the player bar — without moving any player/queue state out of the layout-level AudioPlayerProvider (project memory; the cascade stays at layout level) and without changing the streaming/playback seam.

Four Daniel-stated capabilities, gated by a single new Queue toggle button (shown only when a queue is loaded, mirroring the skip-affordance gating so single-track play is byte-for-byte its pre-queue self):

  1. Queue button in the bar — below the transport controls, left of the timestamp.
  2. Non-Fixed (overlay) mode — a centered, mostly-square panel (borrowing the Phase 15 visualizer-popover idiom) listing the queue with drag-reorder + per-track removal.
  3. Fixed (embed) mode — the queue list is always shown below the bar controls (not a toggle), read-only for shared release queues (no reorder, no remove); the embed iframe is resized to fit the panel.
  4. Add to Queue affordance — an icon button + tooltip beside every detail-page play button for a release (→ EnqueueRange) or track (→ Enqueue), lighting up the dormant Enqueue path. Scoped to the detail-page play sites (Cut header, Cut track rows, Session/Mix hero); ReleaseGallery browse-grid cards are excluded (no play button today — deferred per OQ10, captured in TODO.md).

Architectural spine. Engine grows two additive membersMove(from, to) and RemoveAt(index) — interop-free state mutations that re-emit QueueChanged and never re-stream or interrupt the playing track (the engine's stated open/closed posture; existing members untouched), plus a small additive Enqueue-into-dormant affordance (OQ8: append leaves a coherent CurrentIndex so the next play/skip is correct, without auto-playing). Both view modes render one shared QueueList presentational component off the same cascaded IQueueService.Items, differing only in presentation + an Editable flag (project memory: one source, multiple views). Reorder/remove run safely during prerender (no JS) — only playback transitions touch interop.

Sequenced as three waves. 17.1 → {17.2, 17.3}. 17.1 (engine Move/RemoveAt + the shared QueueList view) is the cold-start prerequisite, settled and independent of the UI decisions — it can begin immediately. Landed: 2026-06-19 on dev. 17.2 (docked overlay, editable, MudDropContainer reorder) and 17.3 (Fixed embed panel + snippet resize — the OQ1 Option-A-vs-B feasibility call is made here) hang off it and are largely parallel. Add-to-Queue split to a standalone 17.4 (needs only the existing Enqueue/EnqueueRange, not 17.1's new members). Landed (17.2): 2026-06-19 on dev. Landed (17.4): 2026-06-19 on dev. Landed (17.3): 2026-06-19 on dev.

Phase 17 is complete. All four waves (17.1 engine additions + shared QueueList, 17.2 docked overlay, 17.3 Fixed embed panel + iframe resize handshake, 17.4 Add-to-Queue affordance) landed on dev 2026-06-19. See COMPLETED.md §17 for the full completion records.

Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and the open-question set: product-notes/phase-17-player-queue-view.md.

Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).

  • OQ1Option A, confirmed (17.3) — collapse/expand toggle with postMessage → host resize handshake implemented. EmbedSnippetBuilder.ForRelease carries the host-side listener; embed-frame.ts posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
  • OQ2yes, both modes — clicking a queued row jumps playback to that track in the docked overlay and the read-only embed; reuses PlayRelease(Items, index).
  • OQ3 + OQ11 (jointly) → the currently-playing track cannot be removed at all — no "remove current" action; the × is suppressed on the current row. The queue empties only organically (the current track ends with nothing queued after). The engine's RemoveAt-of-current path (17.1) stays as defensive, UI-unreachable behavior.
  • OQ4MudDropContainer for now (C6 softened — touch-viability is a known risk with a planned pivot path, not a pre-ship blocker).
  • OQ5yes, Clear in the overlay header — but Clear must not stop or remove the currently-playing track (it keeps playing and stays in the queue; only the other queued tracks clear).
  • OQ6fixed sensible height with internal scroll past N rows (not grow-to-cap; affects EmbedSnippetBuilder.ForRelease).
  • OQ7Material icons for now (QueueMusic / PlaylistAdd; bespoke DDIcons glyph not pursued in Phase 17).
  • OQ8pure append (add ≠ play; first add into a dormant queue leaves a coherent CurrentIndex via the 17.1 engine affordance, no auto-play).
  • OQ9exclude StreamNowButton (no fixed track until one resolves).
  • OQ10deferred (cards get no Add-to-Queue in Phase 17; deferred card work captured in TODO.md).
  • None block 17.1.

Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)

Wire all three AuthBlocks account-creation paths into the DeepDrftManager CMS — the admin user-administration surface (provision users, manage accounts, manage registration invites, manage role permissions) and the public-facing self-service registration form. All three paths live on DeepDrftManager (the CMS app); there are NO changes to DeepDrftPublic in this phase. Daniel's framing: "already part of the AuthBlocks library so we just wire it up." Correct — and further along than it implies: almost everything landed by side-effect of the prior startup separation. Full design, the verified three-path model, the already-done-vs-remaining split, the SkipperHaven pattern + concrete deltas, scope boundaries, and open questions: product-notes/phase-19-user-management-cms.md.

The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:

  1. Admin provisions directlySuperRegister.razor/account/superregisterPOST api/auth/admin-register (UserAdmin-gated, working). Creates a live account now.
  2. Public self-serviceRegister.razor/account/registerPOST api/auth/register (unauthenticated, no role gate, working). A public-facing CMS route, exactly like the CMS /account/login page — invited user redeems a code (pre-filled from the invite email's deep link) and self-registers, all on the CMS host.
  3. Admin provisions a token + triggers the invite emailNewRegistration(Form).razor/useradmin/registrations/newPOST api/pendingregistration/create (UserAdmin-gated). Sends a real email server-side via Mailtrap (RegistrationEmailTemplate + IGeneralEmailSender, configured in DeepDrftAPI from environment/authblocks.json) — not stubbed.

Host-model correction (Daniel, 2026-06-19). A prior revision placed public registration (path 2) on DeepDrftPublic as a cold-start integration. Wrong — there are NO DeepDrftPublic changes. Public registration is an unauthenticated route on the CMS app, mirroring the CMS's already-public /account/login. The only genuinely stubbed surface is Reset Password (Users.razor, // todo; no backing endpoint in AuthRoutes) — handled separately by Daniel in the AuthBlocks repo (see product-notes/authblocks-password-reset-brief.md).

Most wiring already landed by side-effect. The AuthBlocks startup separation (PLAN_authblocks_trackmanager.md, 2026-05-25) + login/logout integration already put the entire surface in place on DeepDrftManager: Cerebellum.AuthBlocks.Web referenced, ConfigureAuthServices registers every client + ViewModel and the JwtAuthenticationStateProvider path 2 needs, the router discovers every page (AdditionalAssemblies) — including the public /account/register — and the DeepDrft Admin role inherits UserAdmin (the seeded admin passes the gate with no change). The pages ship in a published RCL, so the worried-about "extract pages into an RCL" fork does not arise.

Two real gaps remain. (a) No navCmsLayout is just an app bar + Home button, so nothing links to /useradmin/* or /account/superregister (admin surface invisible). (b) Wrong layout for public pagesRoutes.razor uses a static DefaultLayout="typeof(CmsLayout)", so an unauthenticated visitor to /account/register (or /account/login) lands in the authenticated app shell instead of the lean splash.

SkipperHaven is the canonical pattern. SkipperHaven (same AuthBlocks library) exposes login + register as public/unauthenticated routes correctly by making Routes.razor's DefaultLayout auth-state-driven — unauthenticated → home/lean layout, authenticated → app shell (resolved in OnParametersSetAsync off the cascaded AuthenticationState). The concrete delta DeepDrftManager needs is exactly one change (spec §2c): make its DefaultLayout auth-state-driven, resolving CmsHomeLayout (unauth) vs. CmsLayout (auth). Everything else SkipperHaven does — service wiring, page discovery, both layouts — DeepDrftManager already has (it even already ships CmsHomeLayout, used by the / home splash). So path 2 is one router edit, not a host integration.

One host (DeepDrftManager), two parallel tracks (different files), then verify + theme.

  • 19.1 — CmsLayout navigation (admin-nav track; the main code wave). DECIDED nav shape: G1-b. Add a MudDrawer + toggle to CmsLayout.razor; mount the shipped UserAdminMenu fragment (self-gates to UserAdmin+) alongside the existing CMS destinations (Catalogue / Releases / Upload); surface both admin account paths (path 1 SuperRegister + path 3 via the Registrations link); do not surface the redundant bare NewUser (OQ2 resolved). Scope: CmsLayout.razor. No service, API, data, or AuthBlocks-source change. Landed: 2026-06-19 on dev.
  • 19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a. Make Routes.razor's DefaultLayout auth-state-driven (mirroring SkipperHaven, spec §2c D1): cascade Task<AuthenticationState>, resolve _currentLayout = authed ? CmsLayout : CmsHomeLayout, bind DefaultLayout="@_currentLayout". This renders /account/register (path 2) and /account/login in the lean CmsHomeLayout for unauthenticated visitors. Scope: Routes.razor only. No new layout (both exist), no package, no service, no AuthBlocks-source change. Landed: 2026-06-19 on dev.
  • 19.3 — End-to-end verification (after 19.1 + 19.2). Exercise provision-now (path 1), invite-email send (path 3) incl. that the invite link {ReturnHost} points at the CMS origin, list/deactivate users, permissions against a running DeepDrftAPI; confirm cross-host token + CORS, and the full path-3→path-2 loop on the single CMS host (admin provisions → email arrives → invitee redeems on the CMS /account/register in the lean layout). Mostly test; any break is likely a one-line config fix (esp. Mailtrap creds + return host) or an upstream AuthBlocks issue.
  • 19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3). Accept the CMS palette for the MudBlazor-default grids and the public pages now in CmsHomeLayout; fix only contrast/legibility breaks. Bespoke restyle deferred.

Deferred (note, don't build): admin dashboard landing (G1-c); working Reset Password (separate AuthBlocks-repo effort); bespoke restyle of the AuthBlocks grids; a visible public Register nav link (invite-only — the email deep link is the entry point); bumping Cerebellum.AuthBlocks.Web 10.3.33 → 10.3.35 (housekeeping).

Explicitly not needed: any change to DeepDrftPublic (corrected host model — all three paths are CMS); extracting AuthBlocks pages into a new RCL; new DI/service wiring, role seeding, or Auth connection string (all present); editing the AuthBlocks Login/Register pages' layout (impossible without forking the RCL — G0-a fixes layout host-side instead).

Open questions for Daniel (spec §6). Resolved: (1) nav shape G1-b; (2) surface path 1 + path 3, hide bare NewUser; (5) Reset Password non-functional in v1, handled separately; (6) host model — all three on the CMS, no DeepDrftPublic changes; (7) public-route layout G0-a (auth-state-driven DefaultLayout, reusing CmsHomeLayout). Still open: (3) admin dashboard defer (recommend defer); (4) package bump (recommend leave); (8) a logged-in admin visiting /account/register sees it in the app shell under G0-a (recommend accept). None block 19.1 or 19.2.

Adjacency to the deferred Identity / accounts backlog item (below). That item is about public, per-user identity (favourites, listening history, playlists). This phase is CMS account management only (admin surfaces + invite-based self-registration) — same AuthBlocks substrate, different surface. They are not the same work; this phase does not satisfy or depend on that one.


Phase 18 — Opus Low-Data Streaming (dual-format lossless + Opus delivery)

The concrete realization of the long-deferred "Non-WAV formats" intent (CONTEXT.md §5). Daniel's direction (2026-06-23): two delivery formats per track — the existing lossless WAV path, and a new low-data Ogg Opus (fullband, 320 kbps) path — so the listener gets a choice, with Opus the bandwidth-friendly default-candidate. Lossless streaming becomes optional, not the only path. The bespoke Web Audio decode→schedule graph is retained by deliberate choice — Opus feeds the same IFormatDecoder seam, not an HTML <media> element or MSE (the decision shared with Phase 21 OQ5). Sequenced BEFORE Phase 21 — windowing must work across both formats. Surfaces: ingest/preprocessing in DeepDrftContent (AudioProcessor/router/WaveformProfileService) + DeepDrftAPI (UnifiedTrackService.UploadAsync, replace-audio); delivery/decode in DeepDrftAPI (stream endpoint + Range) + DeepDrftPublic proxy + DeepDrftPublic.Client player stack + DeepDrftPublic/Interop/audio TS decoders. Full design, the three directions with SOLID/road-not-taken rationale, the storage and delivery options, the Opus decoder + seek math, acceptance criteria, open questions, and wave decomposition: product-notes/phase-18-opus-low-data-streaming.md.

Much further along than the backlog line implies (verified 2026-06-23). The multi-format substrate already exists on both sides: the producer-side AudioProcessorRouter routes .wav/.mp3/.flac and TrackContentService.AddTrackAsync is format-agnostic (it stores originals, no transcode); the decoder-side AudioPlayer.createFormatDecoder is a wired strategy registry dispatching on Content-Type (WAV/MP3/FLAC decoders all present — correcting the Phase 21 spec's stale "implemented-not-wired" note). The actual gap is Daniel's specific ask: (1) a transcode-at-ingest step that derives an Opus 320 artifact per track (nothing derives Opus today), and (2) a per-format delivery selection so one track serves as either WAV or Opus on request.

Open questions RESOLVED (Daniel, 2026-06-23). OQ1 selection UX → global, via a new public-site Settings menu (not a bare app-bar control); OQ2 default → Opus by default, capability-gated (defer network-awareness); OQ3 remembered → persisted via the dark-mode seam (cookie → prerender-read → PersistentComponentState → client cookie service); OQ4 → always-on Opus + Backfill-Opus; OQ5 → Ogg Opus; OQ6 transcode model → background job after the file is available, with a visible Post-Processing phase on the CMS upload meter. OQ7 (seek-index granularity) → 0.5 s (half-second) buckets (~115 KB index for a 1-hour mix).

Architectural spine — a derived artifact set + a delivery param + one new decoder + a precomputed accurate seek index; leaf implementations only, zero changes to existing format code (the strong OCP signal). Transcode is a new processor sibling in DeepDrftContent, invoked post-store alongside WaveformProfileService as a background job (a 1 GB WAV transcode must not block the upload; the source is stored and the track plays lossless first, then Opus is derived) — mirroring the landed waveform-datum pattern (derive at ingest, regenerate via a CMS bulk action + ApiKey endpoint). The Opus bytes are a derived artifact stored like the high-res waveform datum (recommend a dedicated track-opus vault, the track-waveforms precedent; final call staff-engineer's). Delivery adds a ?format=opus|lossless param (mirroring the existing offset param threading through TrackProxyController) resolved server-side to the right artifact + content-type, with a lossless fallback when no Opus artifact exists (additive, never 404/silence). The player gains one OpusFormatDecoder (IFormatDecoder): Ogg-page-aligned segmenting (OggS scan — the FLAC frame-sync analogue) and OpusHead/OpusTags setup-bytes carry (the FLAC streamInfoBytes analogue). Browser constraint flagged: Ogg-Opus decodeAudioData is Safari-18.4+ only (Chrome/FF long-standing), so the Opus default is capability-gated — fall back to the universal lossless path on browsers that can't decode it.

VBR-safe ACCURATE seeking (Daniel, 2026-06-23 — supersedes the earlier "approximate" hand-wave). Raw byte-offset seek and rough page interpolation are inadequate for VBR Opus — there is no linear time↔byte relationship. The fix is an accurate transfer function built at transcode time (the one moment the whole encoded stream is walked): a precomputed seek index mapping Ogg-page granulepos (48 kHz sample counts → time) → exact byte offset (0.5 s buckets snapped to page starts — OQ7; ~7,200 entries × 16 bytes ≈ ~115 KB for a 1-hour mix). The decode setup header (OpusHead/OpusTags, needed to decode any mid-stream slice) is made available too. Recommended concrete design: one sidecar artifact per track = [setup header][seek index], built at transcode, stored beside the Opus bytes, fetched once on track load, parsed into OpusSeekData. Client seek flow: calculateByteOffset(t) binary-searches the index for the exact page offset → Range: bytes=X- fetch (landed Phase 4 primitive, unchanged) → prepend the cached setup header → decode → fine re-sync to t within the bucket. The listener lands at the correct time, not approximately (AC9), without the full PCM in memory — so it composes with Phase 21 windowed refill, which calls the same index resolver. The earlier "approximate page-interpolation" language is rejected.

Constraints/invariants: keep the bespoke graph (no MSE); preprocessing is additive (WAV path untouched, byte-for-byte; a track with no Opus artifact still plays losslessly); reuse the landed Range/offset seek path; no format branches leak outside the new decoder + one selection arm + the transcode/delivery seam; transcode failure must not block ingest; format selection is a delivery-time decision resolving one EntryKey to one of two artifacts (one source, two views — not a second TrackEntity row, which would fracture share/queue/play-count/release identity).

Sequenced as six waves. 18.1 → 18.2 → {18.3, 18.4} → 18.5, with 18.6 (Settings menu) able to run in parallel (it needs only 18.3's format mechanism before its toggle is live). 18.1 (ingest transcode + seek-index + setup-header derived artifacts) is the cold-start prerequisite — nothing downstream has bytes to serve, decode, or seek against until those artifacts exist.

  • 18.1 — Ingest transcode + seek-index + setup-header (cold-start; load-bearing). New OpusTranscodeService/processor in DeepDrftContent, invoked post-store from UnifiedTrackService.UploadAsync alongside WaveformProfileService as a background job (OQ6); produces Ogg Opus fullband 320; walks the encoded stream once to build the granule→byte seek index and extract the OpusHead/OpusTags setup header; stores the Opus bytes and the combined seek/setup sidecar as derived artifacts (recommend a track-opus vault). Failure-tolerant. Independent of the delivery/decoder waves — can begin immediately.
  • 18.2 — Storage + lookup contract. The derived-artifact key/vault convention (Opus bytes + sidecar) + server-side "given EntryKey + format, return the right AudioBinary + content-type (+ the sidecar)," including the lossless fallback. Depends on 18.1.
  • 18.3 — Delivery: ?format=opus|lossless param + sidecar serving + proxy threading. On the DeepDrftAPI stream endpoint (resolves via 18.2), forwarded through TrackProxyController (mirror offset), Range serving the chosen artifact; plus serving the seek/setup sidecar; player sends the format param via TrackMediaClient. Depends on 18.2; parallel-ok with 18.4.
  • 18.4 — OpusFormatDecoder + index-based seek resolver in the player stack. New IFormatDecoder (Ogg-page segmenting via OggS scan, OpusHead/OpusTags setup carry from the cached sidecar, calculateByteOffset that binary-searches the precomputed seek index — NOT interpolation — with an OpusSeekData accelerator holding the parsed index + setup bytes, and the one-time sidecar fetch+parse on track load) + one arm in createFormatDecoder on audio/ogg/audio/opus; capability detection for the lossless fallback. Depends on 18.2; parallel-ok with 18.3.
  • 18.5 — Backfill + replace-audio + end-to-end validation (incl. seek accuracy). "Backfill Opus" CMS bulk action (third sibling to Generate-Profiles / Backfill-High-res), rebuilding Opus bytes + sidecar for existing tracks; replace-audio Opus + sidecar regeneration; the AC1AC10 acceptance pass including AC9 (an Opus seek lands at the correct time, not approximately) and the Phase-21 handshake (Opus windowable via the index resolver + sidecar setup header). Depends on 18.118.4.
  • 18.6 — Public Settings menu + quality toggle (the listener selection UX). New public-site Settings-menu shell (app-bar trigger + MudBlazor menu + a settings-item abstraction + a PublicSiteSettings/ListenerSettings object + the dark-mode-pattern persistence seam: streamQuality cookie, a DeepDrftPublic prerender-read service, PersistentComponentState bridge, client cookie service); the quality toggle is its first occupant (Low-data/Lossless, Opus default, capability-gated)
    • the CMS upload meter's Post-Processing phase (OQ6). Built design-for-adaptability so dark mode can plug in later without restructuring (not migrated now). Depends on 18.3 for the toggle; the menu shell can be built ahead. Splittable (shell, then toggle) if Daniel wants the shell proven first.

Dependency shape: 18.1 → 18.2 → {18.3 ∥ 18.4} → 18.5; 18.6 ∥ (needs 18.3 for the live toggle); 18.1 is the only cold-start wave. Phase-level: 18 precedes Phase 21 (windowed refill consumes the Phase 18 seek-index resolver). OQ1OQ7 RESOLVED (above); OQ7 (seek-index granularity) = 0.5 s buckets. None block 18.1.


Phase 21 — Windowed Streaming Buffer (bounded client memory for long streams)

Bound the client memory a playing track consumes to a small, configurable forward window — independent of total stream length — so a 1 GB+ DJ MIX (Phase 9 Mix medium: a single long track) plays without the whole decoded PCM accumulating in the browser. Public listener site only (DeepDrftPublic.Client player stack + DeepDrftPublic TypeScript audio interop); no CMS, no API endpoint, no schema change.

Sequenced AFTER Phase 18 (Opus Low-Data Streaming) — Daniel, 2026-06-23. Format support (the derived Ogg Opus 320 low-data path, Phase 18) is a prerequisite that comes first; windowing must work across both delivery formats. Phase 21's C5 invariant already anticipated this ("must not foreclose MP3/FLAC"); Opus is now the concrete VBR/paged driver — windowing an Opus stream uses the decoder's accurate index-based byte↔time mapping (OpusFormatDecoder.calculateByteOffset, a binary search in the Phase 18 precomputed seek index — not the exact CBR-WAV byteRate math, and not approximate page interpolation: VBR-safe and exact, per the Phase 18 seek-model resolution 2026-06-23). The windowed refill controller calls the same index resolver an explicit seek does, and a window opening away from byte 0 still decodes via the Phase 18 sidecar setup header. Build the window machinery format-agnostically so it inherits Opus for free.

The network path already streams in adaptive 1664 KB chunks. The accumulation is on the decode side: PlaybackScheduler holds an AudioBuffer[] it never evicts ("Supports pause/resume/seek by retaining all buffers" — its own doc comment). Decoded PCM is larger than the source (Web Audio is 32-bit float per sample/channel — a 16-bit stereo WAV roughly doubles once decoded), so a 1 GB WAV becomes ~2 GB of retained float data. That is the OOM. The fix: hold only a sliding forward window plus a small back-retain, discard already-played buffers, and refill on demand.

Architectural spine — a sliding window keyed on playback position, built as a generalization of the landed seek-beyond-buffer path. The Phase 4 HTTP Range: bytes=X- → 206 primitive already does every plumbing primitive the window needs (discard-buffers-keep-offset via clearForSeek/setPlaybackOffset; fetch-from-offset via TrackMediaClient; decode-header-less-body via StreamDecoder.reinitializeForRangeContinuation; time→byte via IFormatDecoder.calculateByteOffset), just triggered manually and one-shot. The only genuinely new mechanisms are partial eviction on the scheduler and back-pressure on the forward read loop (stop calling ReadAsync above a high-water mark, resume below low-water). Recommended Direction A (sliding window on the existing single forward stream); Direction B (discrete Range-fetched segments — the HLS/DASH/MSE-eviction analogue) held as the documented fallback; Direction C (adopt MSE and let the browser manage the buffer) rejected (OQ5 = NO, Daniel 2026-06-23) — the bespoke Web Audio graph is a deliberate long-term commitment, and the compressed-delivery move that would have justified MSE is met instead by Phase 18 (Opus) feeding the same bespoke graph through the IFormatDecoder seam. Direction A is therefore the permanent destination, not a stopgap MSE would retire.

Invariants that must hold (the §3.5 seam contract). Reuse the Range path, don't fork it; playback- start latency at parity; the IFormatDecoder abstraction untouched (windowing is format-agnostic, so wiring MP3/FLAC later inherits it free); read-only playback (no new control); the single-instance JS decoder stays single-writer (every refill routes through the existing cancellation/drain discipline). The Mix visualizer is provably unaffected — it renders from the preprocessed per-track high-res datum (Phase 10/12), never from live decoded PCM, so evicting played buffers cannot starve it. The 1 GB mix is both the canonical case and the proof the eviction is safe.

Interaction with deferred Phase 1 features (same seam): windowing should land before preload (1.3) — it makes preload of long tracks memory-safe by construction (a staged next-track decoder inherits the bounded scheduler); it makes crossfade (1.4) between two long mixes affordable (the overlap doubles the window, not the track); it adds a minor "don't evict the final window before the gapless boundary" care point for 1.5. It enlarges the error surface (1.6): windowed refill issues mid-stream fetches the listener didn't initiate, one of which can fail deep into a 1 GB mix — so the cheap half of 1.6 (clean refill-failure handling, no wedged player) is folded into this phase's acceptance criteria, not left fully to 1.6.

Full design, the three directions with SOLID/road-not-taken rationale, use cases, acceptance criteria, the open-question set, and the wave decomposition: product-notes/phase-21-windowed-streaming-buffer.md.

Sequenced as four waves. 21.1 → 21.2 → 21.3, with 21.4 validating the whole. 21.1 is the cold-start prerequisite and the load-bearing change — independent of the open questions (window sizes are parameters fed in later).

  • 21.1 — Partial eviction in PlaybackScheduler (cold-start; load-bearing). Drop already-played buffers while keeping the position/index/time-anchor bookkeeping exact against a buffer array that no longer begins at absolute time 0 (today getCurrentPosition/playFromPosition/the schedule loop all assume buffers[0] is the track start). The hardest correctness work in the phase. No refill yet. Independent of the open questions — can begin immediately.
  • 21.2 — Back-pressure on the forward read loop. Stop ReadAsync above the high-water mark, resume below low-water; together with 21.1 this bounds both the played and unplayed regions (the AC1 guarantee). Routes resume/pause through the existing single-loop cancellation discipline. Depends on 21.1.
  • 21.3 — Seek-back-past-window refill. When a backward seek lands earlier than the retained tail, refetch via the existing seek-beyond-buffer Range path pointed at the earlier offset; plus the minimal clean refill-failure handling (the 1.6 adjacency). Mostly reuse of the landed seek path. Depends on 21.1 + 21.2.
  • 21.4 — Validation against the 1 GB target (acceptance). Memory profiling (bounded under 1 GB is the headline), latency parity, edge-to-edge playback, the seek matrix, induced refill failure, visualizer- running, rapid-seek concurrency. Largely measurement; breaks are tuning fixes in 21.1's anchor math or 21.2's water-marks. Depends on 21.121.3.

Dependency shape: 21.1 → 21.2 → 21.3 → 21.4; 21.1 is the only cold-start wave. Phase-level prerequisite: Phase 18 (Opus) lands first so windowing is built against both formats. Open questions for Daniel (spec §6): window-size policy axis (time-based window + memory guard — recommended); seek- back-past-window re-buffer acceptable (recommend yes, symmetric to forward); a hard total in-flight memory cap as a guard rail (recommend yes); window everything vs. only long tracks (recommend everything — one path, short tracks never hit a refill). OQ5 (adopt MSE) — RESOLVED NO (Daniel 2026-06-23): the bespoke graph stays by deliberate choice; recorded considered-and-declined, kept visible per file convention. None block 21.1.


Working with this file

  • Add items by extending an existing phase first; only create a new phase when the addition genuinely doesn't fit any of 15. Phase numbers are organisational, not sequencing.
  • When something lands, move it to COMPLETED.md rather than deleting it. Keep the original "What / Why / Shape" body intact so the history reads as a record of the decision, not just the outcome.
  • Mark genuinely uncertain items [speculative] so future readers can tell what is direction vs. commitment.
  • Open questions belong in the item that raises them, not in a separate "questions" list — they expire when the item does.