29 KiB
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 concurrency —
VaultIndexDirectoryno longer drops the lock before its async disk write; the index file can no longer be clobbered by interleaved writers. - Repository semantics —
TrackRepository.Updatenow fails-fast when anIdis not found instead of silently issuing anINSERT. - 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,
IAsyncDisposableon 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.2 Audio format diversity
- What: Today
AudioProcessor,WavOffsetService, and the JS decoder are PCM/WAV-only.MimeTypeExtensionsalready maps MP3, FLAC, Ogg, AAC, M4A — none are wired. - Why it matters: WAV-only is a real ceiling for any non-internal release. Distribution-grade formats (MP3, FLAC at minimum) are table stakes for a music site.
- Shape: Two seams need a strategy pattern.
- Server side: replace
AudioProcessor.ProcessWavFileAsyncwith a format-router that selects a per-format processor; replaceWavOffsetServicewith a per-format offset strategy (some formats — MP3, Ogg — have natural frame boundaries; FLAC has block headers; AAC has ADTS). - Client side: the JS decoder is currently a WAV byte-walker. For non-WAV, the simplest path is
decodeAudioDataover the full payload (loses streaming-start). The richer path is per-format chunked decoders. Worth a design pass before committing.
- Server side: replace
- Prerequisite: None functionally, but consider settling Phase 4 (HTTP Range) first — native range/cache is much more important for large MP3s than for WAVs.
- Constraint: Spectrum FFT tap currently relies on raw
AudioBuffers throughdecodeAudioData. If a future path usesMediaElementAudioSourceNode(see 4.1), the FFT tap still works but the early-playback story changes.
1.3 Preload / prefetch of the next track
- What: 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
HttpClientrequest kicked off when the current track passes a configurable threshold (e.g. last 10 seconds). Bytes accumulate into a stagedStreamDecoderinstance rather than the live one. Promotion to "current" happens at end-of-stream or on user-selected next. - Prerequisite: Requires a notion of "next track" — today the player only knows the current one. That implies either a playlist/queue model in
IPlayerServiceor a passive "what was the next row in the gallery" inference. - Open question: Does a queue model belong in
IPlayerService, or is the player a single-slot device that a futurePlaylistServiceorchestrates above? Worth a design note before implementation. Capture in product notes when picked up.
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
PlaybackSchedulerinstances suffice — each owns its own gain node, crossfaded viaGainNode.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 firstAudioBufferSourceNode.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
processStreamingChunkaborts 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);awaitresolves immediately and the nextinitialize()can run against a not-yet-closed context.- iOS Safari < 15 had streaming-fetch quirks;
HttpCompletionOption.ResponseHeadersReadbehaviour 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 — detectwebkitAudioContextand pollstate === "closed"with a short timeout instead of trusting theawait. 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.
Phase 2 — Product surface: gallery, browsing, ingestion
These follow from CONTEXT.md §5. Direction is strongly implied but no specific UI has been committed.
2.2 Album and genre views
- What:
TrackCardalready renders album/genre/release date; the data is there. Missing are gallery groupings (album view, genre view), filters, and the API-side support for filter expressions inTrackService.GetPaged. - Why it matters: The track gallery is the only working content surface. Multiple views over the same library is how it earns the "gallery" name.
- Shape: Per
CONTEXT.md §6, the convention is one source of truth, multiple views over it. New views should consume the sameTracksViewModel/PagedResult<TrackEntity>and differ only at the rendering layer.TrackService.GetPagedextended to accept a filter expression (or a simple structured filter DTO).PagingParameters<T>extended with aWhere: Expression<Func<T, bool>>?or a parallelFilterParameters<T>— pick one to avoid drift.- New routes (
/albums,/genres) consume the same VM with different grouping / filter inputs.
- Prerequisite: 2.1 for any view that prominently features cover art (album view especially is impoverished without it).
2.5 "Stream Now" — random-track instant play
- What: The nav-bar "Stream Now ▶" CTA (desktop and mobile, in
DeepDrftMenu.razor) today just navigates to/tracks. Change it to pick a random track from the library and start playing it immediately, in place, without forcing the user onto the gallery page. - Why it matters: It is the single most prominent call-to-action on the site and currently does the least interesting thing — it dumps the listener on a grid and asks them to choose. "Stream Now" should mean now: one click, music plays. It is also the lowest-friction way for a first-time visitor to hear the collective's output, which is the whole point of the public site. Borrowed pattern: the "shuffle play" / "I'm feeling lucky" affordance (Spotify's shuffle, Bandcamp's "play random").
UX flow
- User clicks "Stream Now ▶" (desktop CTA or mobile menu item).
- Button enters a brief loading affordance (disabled + subtle pulse/spinner) while a track is selected — the selection requires at least one HTTP round-trip, so this is not instantaneous.
- A random track is chosen from the full library (see Random selection strategy).
- The player begins streaming that track via the existing
AudioPlayerBardock at the bottom of the layout. The dock is already cascaded into every page byAudioPlayerProviderinMainLayout, so it appears/animates in exactly as it does when a gallery card is clicked. - The user does not navigate. They stay on whatever page they were on (most likely
Home). Music plays; the dock is the player surface. (See Navigation behavior for the rationale and the rejected alternative.) - On mobile, the menu closes (
CloseMobileMenu) as part of the click, same as the existing nav links.
Edge cases
- Empty library (
TotalCount == 0): No track to play. The button should surface a non-blocking, transient message ("No tracks yet") and do nothing else. Do not navigate, do not error-toast aggressively. This is a legitimate cold-start state, not a failure. - Metadata fetch fails (HTTP error on
GetPage): Surface a transient error on the button ("Couldn't reach the library — try again"), re-enable the button, do not navigate. Reuse the existingApiResultfailure check pattern (result is { Success: true, ... }). - Track fails to stream (selected track is valid metadata but the audio stream errors): This is already handled downstream by
StreamingAudioPlayerService/StreamingErrorHandlerand surfaced throughIPlayerService.ErrorMessageand the dock. Stream Now does not need its own handling here — it hands off to the sameSelectTrackStreamingpath every other play uses, and inherits that path's error behavior. Do not duplicate stream-error handling in the menu. - Player already playing something: Stream Now interrupts it and starts the random track. No confirmation prompt — "Stream Now" is an explicit user command to play something new. (If a future "is something already playing?" courtesy is wanted, capture it then; not in scope now.)
- Repeat clicks / same-track-twice: Acceptable for v1 to occasionally re-pick the currently-playing track (uniform random over a small library will do this). If it becomes annoying, a cheap "exclude
PlayerService.CurrentTrack?.Id" filter on the candidate set is a one-line follow-up — note it, don't build it.
Random selection strategy
The API is paginated (GET api/track/page → PagedResult<TrackDto> with Items, TotalCount, PageNumber, PageSize, unauthenticated). The library is small today but the strategy must not assume that.
Chosen strategy — two-request, count-then-fetch (uniform over the whole library):
- Fetch page 1 at the existing default page size (
GetPage(1, pageSize)). ReadTotalCount. - If
TotalCount == 0→ empty-library path. - If
TotalCount <= pageSize→ all tracks are already in the page-1Items; pickItems[Random.Shared.Next(Items.Count)]. One request, done. - Otherwise compute a uniform global index
i = Random.Shared.Next(TotalCount), derive its pagep = i / pageSize + 1and in-page offseto = i % pageSize. Ifp == 1, reuse the page-1 result already in hand; else fetchGetPage(p, pageSize)and pickItems[o]. At most two requests.
This gives true uniform-random selection across the entire library with at most two round-trips, and degenerates to a single request for any library that fits in one page (the common case today). It does not fetch the whole library, and it does not bias toward page 1.
Alternatives considered (and why not):
- Page-1-only random: fetch page 1, pick random from
Items. One request, dead simple — but only ever plays from the first page once the library exceeds one page. A silent correctness cliff exactly when the library grows, which is the smell this team treats as a design failure. Rejected. - Server-side
GET api/track/randomendpoint: cleanest possible client (one request,ORDER BY RANDOM() LIMIT 1server-side, no count math). Genuinely better long-term — it is O(1) client work, leaks no pagination assumptions, and is the right home for "exclude currently-playing" logic. But it is a new API surface (DeepDrftAPIendpoint +TrackServicemethod +TrackClientmethod) for a feature that the two-request client strategy already serves correctly. Deferred, not rejected — see open question. If/when album/genre views (2.2) or search (2.3) add server-side filtering, arandomendpoint that honors the same filter ("shuffle within this genre") becomes clearly worth it, and the client should migrate to it then.
Player integration seam
The architecture already supports this with no new player seam. The player is a cascading singleton:
AudioPlayerProvider.razorcascadesIStreamingPlayerService(IsFixed="true") across the entire layout fromMainLayout.DeepDrftMenuis rendered inside that layout, so it can take[CascadingParameter] IStreamingPlayerService PlayerServiceexactly asTracksViewandTrackDetaildo.- Playing a track is one call:
PlayerService.SelectTrackStreaming(track). This is the live entry point (SelectTrackdelegates to it; the buffered path is dead).TrackDetail.PlayTrackis the precedent to mirror.
So the launch logic is not duplicated from TracksView — both call into the same SelectTrackStreaming. The only new code is the random-selection helper, which is a data concern, not a player concern.
Where the selection helper lives: put it behind a small service rather than inline in the menu component, to keep DeepDrftMenu a presentation surface (per the project's MVVM convention — components render and dispatch only).
- New method on
ITrackDataService/TrackClientDataService:Task<ApiResult<TrackDto?>> GetRandomTrack(), implementing the count-then-fetch strategy above over the existingTrackClient.GetPage. This keeps all track-fetch concerns on the one seam components already inject, and means the SSR/WASM split is handled for free (the same wayGetPage/GetTrackalready are). DeepDrftMenuinjectsITrackDataServiceand the cascadedPlayerService, and gets aStreamNow()handler: callGetRandomTrack(), on success callPlayerService.SelectTrackStreaming(track), on empty/failure show the transient message.
Critical constraint — AudioContext user-gesture. Browsers (Safari most strictly) only allow an AudioContext to start inside a user-gesture call stack. SelectTrackStreaming starts the context. Stream Now does an await GetRandomTrack() (network) before it can call SelectTrackStreaming — and an intervening await can lose the gesture context on Safari, leaving the context suspended and no audio despite a "playing" state. This is the one real seam decision. Two acceptable mitigations, in order of preference:
- Preferred: ensure the
AudioContextis created/resumed at the start of the click handler, before the network await — i.e. an explicit "warm the context" call synchronous with the gesture, then fetch, then stream.AudioInteropService.CreatePlayerAsyncalready exists and polls readiness; the player likely needs a cheap "resume context now" entry point callable from the gesture. IfInitializeAsyncis safe to call eagerly inside the gesture, that is the hook. Flag for the implementer: confirm whether an existing method resumes a suspended context synchronously; if not, this is a small addition to the player service, not a redesign. - Fallback (if the above is fiddly): accept that the first Stream Now click after a cold page load may require the context to warm, and rely on the same gesture-handling the gallery already uses (gallery play works today, so the existing path already satisfies the gesture requirement when the call is close enough to the click). Validate on Safari before considering this done — see Phase 1.7, which already tracks Safari AudioContext quirks.
Navigation behavior
Decision: play in place, do not navigate.
- Play in place (chosen): The dock is a layout-level surface that already persists across navigation. The user clicked "Stream Now," so the valuable thing — audio — happens immediately wherever they are. Most clicks come from
Home, and keeping the listener on the hero/about page while music starts is a better first impression than yanking them to a grid. The player bar gives them full transport control without the gallery. - Navigate to
/tracksthen auto-play (rejected): Pros — lands the user somewhere they can pick a next track, and the gallery would reflect the now-playing track via its existing active-card state. Cons — it conflates "play something now" with "go browse," adds a navigation the user didn't ask for, and reintroduces the prerender/persisted-state dance (tracks-pagekey) for a side effect. If we later want a "and show me the gallery" behavior, that is a second, distinct affordance ("Browse" already exists as nav), not what "Stream Now" should do. - Leave room for: a future variant where Stream Now also navigates if clicked from a context where the dock would be visually awkward. No evidence that context exists today; do not build for it.
Acceptance criteria
- Clicking "Stream Now ▶" (desktop CTA) with a non-empty library selects a track uniformly at random across the entire library (not just page 1) and begins streaming it via the existing dock, without navigating away from the current page.
- Clicking "Stream Now ▶" in the mobile menu does the same and closes the mobile menu.
- Selection issues at most two HTTP requests, and exactly one when the library fits in a single page.
- With an empty library, the button shows a transient "no tracks" message and does not navigate or throw.
- With a failed metadata fetch, the button shows a transient error, re-enables, and does not navigate.
- A track that streams-errors after selection surfaces through the existing player error path — no new error handling in the menu.
- The menu component contains no track-fetch logic inline: selection goes through
ITrackDataService.GetRandomTrack(); playback goes throughPlayerService.SelectTrackStreaming. No duplication ofTracksView's launch logic. - Audio actually plays on the first click after a cold load on Chrome and Safari (the user-gesture/AudioContext constraint is satisfied). If Safari needs the context warmed inside the gesture, that hook is added to the player service, not worked around in the menu.
- While selection is in flight, the button is disabled to prevent double-launch.
Decision: server-side endpoint
- Decided (2026-06-07): Add
GET api/track/randomtoDeepDrftAPIas part of this feature. The client'sGetRandomTrack()calls this endpoint directly rather than building the selection strategy client-side. Rationale (Daniel): "you don't build a skyscraper from the top down" — the endpoint is the right foundation, and it is the natural home for "exclude current track" and "shuffle within filter" when 2.2/2.3 land server-side filtering. - New API surface:
GET api/track/random(unauthenticated, mirroringGET api/track/page) returning a singleTrackDtoviaORDER BY RANDOM() LIMIT 1(or the EF-Core equivalent) server-side. NewTrackServicemethod +TrackClientmethod to reach it. - Spec sections superseded by this decision (the two-request client strategy was written before the endpoint was chosen — read them as historical context, not as the build target):
- Random selection strategy (above): the count-then-fetch math no longer applies.
GetRandomTrack()is now a single call toGET api/track/random; the two-request strategy and its "at most two round-trips" property are obsolete. - Acceptance criteria: "Selection issues at most two HTTP requests, and exactly one when the library fits in a single page" → selection issues exactly one request (
GET api/track/random). "selects a track uniformly at random across the entire library" still holds; it is now satisfied server-side. TheITrackDataService.GetRandomTrack()seam is unchanged — only its implementation moves from client math to a single endpoint call.
- Random selection strategy (above): the count-then-fetch math no longer applies.
2.3 Search and filter on the gallery
- What:
TracksViewModelexposes sort but no filter.TrackService.GetPagedaccepts only sort. Simple text search acrossTrackName/Artist/Albumis the obvious first cut. - Why it matters: Once the library has more than ~30 entries, sort-only browsing is friction.
- Shape: Same extension to
GetPagedas 2.2. UI is a debounced text input bound to the VM's filter property. EF Core translatesContainsto SQLiteLIKE. - Prerequisite: Fold into 2.2 if both are being done — the same
GetPagedextension serves both. Doing them separately doubles the API churn.
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 polymorphicMediaEntitywith discriminator. The choice affects how much code inTrackService/TrackControllercan be reused. - New vault type(s).
MediaVaultType.Mediaexists and is the obvious home for video; sessions are probably stillAudio. - New routes, new UI surfaces, new player considerations (video has its own playback element and does not go through the WAV decoder).
- Likely new entity table(s) sibling to
- 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.1 HTTP Range + CDN caching
- What: Today's
?offset=query parameter defeats HTTP caching — a CDN sees?offset=1234567as a distinct URL from the un-offset request. The architecture re-invents byte-range on top of a custom query param. - Why it matters: Material once the site has real listener traffic. Also relevant to non-WAV formats (1.2) where decoder-side seek is cheaper natively.
- Shape: Two intertwined moves.
- Server:
LoadResourceStreamAsyncreturning an openFileStreaminstead ofLoadResourceAsyncmaterialising the whole buffer.File(stream, mime, enableRangeProcessing: true). TheWavOffsetServicesynthesised-header path becomes a special-case rather than the default. - Client: consider
MediaElementAudioSourceNodeinstead of (or alongside)decodeAudioData-fedAudioBufferSourceNodes. Native seek, native range, native cache; FFT tap on the audio graph still works for the spectrum visualiser.
- Server:
- Prerequisite: None functionally, but the audit explicitly flagged this trade-off as architecture-intentional — the current path was chosen because spectrum analysis wants
AudioBuffers. Re-deciding the trade-off is itself part of the work. - Constraint: A move to
MediaElementAudioSourceNodechanges the early-playback story (the element handles buffering, not us). Worth a design pass.
4.2 Server-side stream from disk (no buffer materialisation)
- What:
LoadResourceAsync<AudioBinary>reads the entire file into memory beforeFile(file.Buffer, mimeType)returns it. A 100 MB WAV is a 100 MB LOH allocation per request. - Why it matters: Scaling ceiling. Currently fine for a small audience and small library; not fine if either grows.
- Shape: Folds into 4.1 — the same
LoadResourceStreamAsyncoverload solves both. Listed separately because either could land without the other (you could stream from disk while still using the?offset=query path, or you could move toRangeheaders while still buffering).
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
DeadLetterLogrecording orphanedentryKeys 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.mdfiles need writing/rewriting per the brief inDOC_PLAN.md. Five are rewrites (drift from the.NET 10upgrade and structural moves); three are new (DeepDrftWeb.Services,DeepDrftContent.Services— the two libraries where most domain logic now lives — plus the open question onDeepDrftContent.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, referencingMediaPaththat has beenEntryKeyfor two migrations, describing aFileDatabase/tree insideDeepDrftContentthat has moved out, and missing entirely for the two*.Serviceslibraries. - 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.mdjudgement call before that file changes (retire, keep + refresh, or replace with a CLAUDE.md). The other seven can proceed without that decision.
Cross-cutting / not yet themed
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. ITrackServiceinterface. 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 1–4 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.
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.
- When something lands, move it to
COMPLETED.mdrather 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.