Files
deepdrft/CLAUDE.md
T
daniel-c-harvey ca44979b08
Deploy DeepDrftAPI / Build, Publish & Bundle (push) Successful in 2m25s
Deploy DeepDrftManager / Build & Publish (push) Successful in 1m28s
Deploy DeepDrftPublic / Build & Publish (push) Successful in 4m2s
Package install tarball / package (push) Successful in 6s
Deploy DeepDrftAPI / Deploy (push) Successful in 1m33s
Deploy DeepDrftManager / Deploy (push) Successful in 1m59s
Deploy DeepDrftPublic / Deploy (push) Successful in 1m22s
docs: record Opus/derived read-path streaming and index-only opus-status
2026-06-26 15:32:18 -04:00

30 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code when working with code in this repository.

Architecture Overview

DeepDrftHome is a net10.0 solution consisting of ten projects implementing a dual-database media management system for the DeepDrft electronic music collective, split into two independent Blazor applications: the public site (DeepDrftPublic) and the CMS (DeepDrftManager).

Core Projects

  • DeepDrftPublic: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for api/track/* (metadata listing and audio streaming), crawl-directive endpoints (GET /robots.txt and GET /sitemap.xml, environment-gated via IWebHostEnvironment.IsProduction() directly — server-side only, no PersistentState bridge — served by CrawlDirectiveController with pure builders in Seo/RobotsTxt.cs and Seo/SitemapXml.cs), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
  • DeepDrftPublic.Client: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public /about editorial page (Pages/About.razor — three-movement "Liner Notes" editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live WaveformVisualizer), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via about-rail.ts IntersectionObserver interop; registered in Layout/Pages.cs). Home hero stat row (NowPlayingStats.razor) is live-data-backed via IStatsDataService / StatsClient (named "DeepDrft.API" client) with a PersistentComponentState prerender bridge; RuntimeFormat helper converts mix runtime seconds to hh:mm. SEO component (Controls/SeoHead.razor + Common/SeoModel, SeoJsonLd, SeoOptions, SeoUrls, SeoEnvironment): SeoHead is a presentational <HeadContent> emitter (one line per page, no fetch); SeoModel named factories (ForRelease/ForHome/ForAbout/ForBrowse/ForNotFound) encode the medium→schema.org mapping in one place; SeoJsonLd builds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping; SeoOptions holds site-wide config (BaseUrl https://deepdrft.com, title suffix, default OG image seam, IG sameAs) registered via the static Startup seam; SeoEnvironment is a scoped [PersistentState] bridge (mirrors DarkModeSettings) seeded in DeepDrftPublic/Components/App.razor from IWebHostEnvironment.IsProduction() — robots defaults to index,follow only in Production, noindex,nofollow everywhere else (fail-safe is noindex); per-page SeoModel.Robots overrides the default. Tags are present in prerendered HTML (rides the existing PersistentComponentState bridge; no new fetch). Canonical/OG origins come from SeoOptions.BaseUrl (config), not window.location — no window at server prerender and the origin cannot be derived behind the nginx proxy. Consumed by the public site.
  • DeepDrftManager: ASP.NET Core host. Blazor Web App with server-rendered InteractiveServer render mode. Always uncrawlable: a static wwwroot/robots.txt (Disallow: /, no env gate) plus a blanket <meta name="robots" content="noindex,nofollow"> in Components/App.razor — defense in depth so the CMS is never indexed regardless of how it is discovered. Hosts all CMS Razor components and pages under Components/Pages/Cms/, Components/Pages/Tracks/, Components/Layout/CmsLayout.razor, and Components/Shared/ (all inlined from the former DeepDrftCms RCL). Public entry point: Components/Pages/Home.razor (@page "/", no [Authorize], uses lean CmsHomeLayout) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to /catalogue via RedirectToCatalogue. Routes.razor resolves DefaultLayout from the cascaded Task<AuthenticationState>: unauthenticated → CmsHomeLayout, authenticated → CmsLayout; this means the AuthBlocks Login/Register pages (which declare no @layout) render in the lean layout for unauthenticated visitors. CmsLayout carries a left MudDrawer (app-bar hamburger toggle) holding the CMS destinations (Catalogue /catalogue, Releases /releases, Upload /tracks/upload), the AuthBlocks UserAdminMenu fragment (self-gates to UserAdmin+, links Users/Registrations/Permissions), and a "Provision User" link to /useradmin/users/new wrapped in a HierarchicalRoleAuthorizeView (UserAdmin-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (Components/Pages/Index.razor) lives at @page "/catalogue" and remains [Authorize]-gated with CmsLayout; its cards are CUTS / SESSIONS / MIXES, each deep-linking to /releases?medium=<medium> with the matching tab pre-selected. The consolidated browse surface is Components/Pages/Tracks/Releases.razor (@page "/releases"): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in CmsAlbumBrowser's expanded child-row track table. Old list routes /tracks, /tracks/albums, /tracks/archive are kept as aliases on Releases.razor so bookmarks don't 404; operational sub-routes (/tracks/upload, edit routes, etc.) remain at /tracks/*. Gated by AuthBlocks login and hierarchical Admin role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via ICmsTrackService / CmsTrackService injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in BatchEdit / BatchTrackList / BatchTrackDetail swaps the vault bytes, regenerates both waveform datums server-side, and re-derives DurationSeconds from the new audio; the track id, EntryKey, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: DeepDrft.Content.Cms (bounded 100 s default, for all non-upload calls) and DeepDrft.Content.Cms.Upload (InfiniteTimeSpan, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single ProgressStreamContent wrapper (Services/ProgressStreamContent.cs); CmsTrackService.UploadTrackAsync adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: BatchUpload.razor calls GET api/track/release/exists as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (NameIdentifier claim) is captured once into _createdByUserId at component initialization (OnInitializedAsync) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is [Authorize]-gated and runs prerender: false, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as releaseId on rows 2..N (the ATTACH path), while BatchEdit.razor uses the same ATTACH path for its legitimate adds-to-existing-release.
  • DeepDrftShared.Client: Razor Class Library. Shared Blazor components consumed by both DeepDrftPublic and DeepDrftManager for consistency across public and admin surfaces.
  • DeepDrftData: Class library. EF Core domain logic: DeepDrftContext, TrackConfiguration, Migrations, TrackRepository, TrackService, TrackManager. Consumed by DeepDrftAPI and tests.
  • DeepDrftAPI: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the track-waveforms vault), release-track join operations, POST api/track/duration/backfill (ApiKey-gated one-time backfill of DurationSeconds for existing rows from vault audio). Stats endpoints: GET api/stats/home (unauthenticated; returns HomeStatsDto with cut track count, per-ReleaseType cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
  • DeepDrftContent: Class library. The FileDatabase implementation in full (Models, Services, Utils, Abstractions, Constants), AudioProcessor, content-side TrackService. Consumed by hosts and tests.
  • DeepDrftModels: Shared contracts. TrackEntity, TrackDto, PagingParameters<T>, PagedResult<T>. Every project references this.
  • DeepDrftTests: NUnit test suite. Comprehensive FileDatabase tests (vault creation, media storage, indexing, factory patterns, utilities). Integration-focused with temp-directory test isolation.

External: NetBlocks (absolute path C:\lib\NetBlocks\). Provides Result, ResultContainer<T>, ApiResult<T>, ApiResultDto<T>.

Database Architecture

Dual-database approach — browser never reaches storage directly:

DeepDrftPublic.Client (WASM)
    ├── HttpClient "DeepDrft.API"     ──►  DeepDrftPublic proxy  ──►  DeepDrftAPI  ──►  EF Core / PostgreSQL (metadata)
    └── HttpClient "DeepDrft.Content" ──►  DeepDrftPublic proxy  ──►  DeepDrftAPI  ──►  FileDatabase / disk (binary)

Server-side (SSR): Both clients point directly at DeepDrftAPI (server-to-server, no proxy hop).
  1. SQL Database (PostgreSQL): Metadata and track info via Entity Framework

    • Connection string: Read from environment/connections.json via CredentialTools.ResolvePathOrThrow("connections") with key ConnectionStrings:DefaultConnection.
    • Entity: TrackEntity with Id, EntryKey, TrackName, Artist, Album?, Genre?, ReleaseDate?, ImagePath?, DurationSeconds?
    • Context: DeepDrftContext in DeepDrftData
  2. FileDatabase: Custom file-based binary storage system

    • Location: ../Database/Vaults (configurable via filedatabase.json)
    • Root contains typed MediaVaults (Media, Image, Audio)
    • Each vault has a JSON index file listing entries + per-entry metadata
    • Entries are user-supplied strings sanitized to [a-zA-Z0-9-] + file extension
    • Binary hierarchy (model shapes): FileBinaryMediaBinary (+ Extension/MIME) → AudioBinary (+ Duration/Bitrate) | ImageBinary (+ AspectRatio). Non-delivery read path (LoadResourceAsync<AudioBinary>) returns a full-buffer AudioBinary — still used for non-delivery operations (e.g., duration backfill). Audio delivery path streams via GetEntryStreamAsync (Opus artifact, track-opus vault) or OpenAudioMediaStreamAsync (lossless source, tracks vault) — a seekable, disk-backed Stream per request, never a whole-file byte[] (read-side OOM fix, parallel to the store-side). The write/store path is streaming: audio processors return a ProcessedAudio plan (metadata + streamed WriteToAsync callback); RegisterResourceStreamingAsync / MediaVault.AddEntryStreamingAsync write bytes to a temp file then File.Move atomic-rename into place — the full AudioBinary buffer is never materialized on this path.
    • Error-handling philosophy: public operations swallow exceptions and return null/false — callers must check return values, not catch.

Key Architectural Decisions

Service projects vs. host projects

The split between host projects (DeepDrftPublic, DeepDrftManager, DeepDrftContent) and *.Services class libraries (e.g., DeepDrftData, DeepDrftContent.Services) is deliberate: hosts own HTTP surface (endpoints/controllers exposed to network), config, DI wiring, and UI components; *.Services are plain class libraries holding domain logic. This separation allows multiple hosts to consume the same service implementations. Within a host, domain logic like CMS mutations lives in host-internal service classes (e.g., CmsTrackService in DeepDrftManager/Services/), injected directly into Blazor components with no in-process HTTP roundtrip.

New domain logic goes in *.Services (shared class libraries) for logic consumed by multiple hosts, or in host-internal service classes (e.g., Services/) for host-specific logic — not in controllers, which should be thin HTTP boundaries.

TrackEntity is a join, not a content blob

TrackEntity holds only metadata. The link to binary content is EntryKey (string) — the entry id inside the tracks vault in FileDatabase. Dual-database add flow:

  1. TrackContentService.AddTrackAsync routes the audio file by extension (AudioProcessorRouter), produces a ProcessedAudio plan (bounded-header metadata + streamed WriteToAsync callback — no whole-file AudioBinary buffer), and stores it in the vault via FileDatabase.RegisterResourceStreamingAsync / MediaVault.AddEntryStreamingAsync (atomic temp→rename on the Linux host). Returns an unpersisted TrackEntity with DurationSeconds populated from the header parse. Wave 1 OOM fix.
  2. DeepDrftAPI.Services.UnifiedTrackService.UploadAsync persists the entity to SQL via DeepDrftData.TrackManager and returns the persisted entity with Id.

If step 1 succeeds and step 2 fails, audio is orphaned in the vault (no rollback today).

The Opus transcode derived-artifact path (OpusTranscodeService.TranscodeAndStoreAsync) also streams its entire pipeline: extension and duration are read from the vault index (no body load); the source bytes are opened via TrackContentService.OpenAudioMediaStreamAsync and bounded-copied to a staging file; the encoded Ogg output is walked from a FileStream via OggOpusParser.WalkAsync(Stream) (bounded one-page-at-a-time buffer; byte-identical to the retained whole-buffer oracle Walk(ReadOnlySpan<byte>)); and stored via RegisterResourceStreamingAsync. The sidecar (a few KB, inherently bounded) retains the whole-buffer write. Completes the store-path OOM-fix arc.

Streaming-first audio playback

The player is not fetch-then-play:

  1. Client calls GET api/track/{id} on DeepDrftContent and receives the first bounded segment (Range: bytes=0-{SegmentSizeBytes-1}, 4 MB) via HttpCompletionOption.ResponseHeadersRead.
  2. StreamingAudioPlayerService reads in adaptive 1664 KB chunks within each segment, pushes each via AudioInteropService.processStreamingChunk.
  3. TypeScript StreamDecoder parses the format header, decodes chunks to AudioBuffers. PlaybackScheduler schedules them on a Web Audio graph.
  4. Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
  5. Seek beyond buffer: if seek target is past what's decoded, StreamingAudioPlayerService issues a new bounded segment starting at the seek byte offset. Server responds 206; player retains the parsed header and feeds the raw continuation into the existing decode pipeline.

Memory bounding (three complementary layers, all required):

  • Raw-queue bound (StreamDecoder): releaseConsumedChunks() front-compacts rawChunks after each aligned segment is decoded, using a discardedBytes absolute cursor so all offsets remain absolute even as the array's front moves. Without this, a long WAV (e.g. a 92-min mix ≈ 970 MB raw) accumulates its entire decoded-from body in rawChunks regardless of the decode-side bounds below.
  • Decoded-queue bound (PlaybackScheduler): evictPlayedBuffers() discards already-played AudioBuffers, capping the scheduler's forward fill to a 96 MB ceiling (Phase 21.2). Decoded PCM is larger than source (Web Audio uses 32-bit float — a 16-bit stereo WAV roughly doubles; Opus decodes to the same float footprint).
  • Network bound (segmented fetch, Phase 21 Direction B): The forward stream is fetched as sequential bounded bytes=cursor-{cursor+4MB-1} Range requests via RunSegmentedStreamAsync; the next segment is fetched only after DrainBackpressureAsync confirms the scheduler is below low-water. Because each segment is fully consumed before the next is issued, the browser holds at most ~one segment of raw bytes. A per-chunk drain inside the segment loop (gated on _streamingPlaybackStarted so it cannot deadlock first-audio) additionally prevents high-density codecs (e.g. Opus, where a 4 MB segment is ~100 s of audio) from decoding the whole segment eagerly ahead of the playhead before the inter-segment gate runs.

Playback stability invariants (streaming-stabilization arc):

  • Back-pressure water marks: forward fill 60s high / 30s low (PlaybackScheduler.DEFAULT_FORWARD_HIGH_WATER_SECONDS / ...LOW_WATER_SECONDS). Production pauses on lookahead ≥ high OR decoded bytes > 96 MB ceiling, whichever first. The time window is a jitter cushion for Opus's async decode ramp; the byte cap is the hard OOM guarantee and is unchanged.
  • Genuine end-of-playback: PlaybackScheduler uses a streamComplete flag (set by setStreamComplete) combined with an underrun_ park/resume state to distinguish a drained-but-still-streaming queue (startup/underrun gap → park, resume on refill) from a truly finished track (stream complete AND queue drained → finishPlayback(), the single genuine-end path). Prevents the false onPlaybackEnded that previously fired when the Opus WebCodecs queue momentarily drained during the decode ramp.
  • Rebuffer hysteresis: Opus playback start and underrun-resume are gated on hasMinimumPlaybackLead() (1s decoded lead, DEFAULT_MIN_PLAYBACK_LEAD_SECONDS); WAV keeps hasMinimumBuffers(6). streamComplete overrides the gate so a short tail still plays out. StreamingAudioPlayerService.TryStartPlaybackAsync() force-starts after MarkStreamCompleteAsync when the threshold was never crossed (ultra-short track protection).
  • Opus AudioContext pre-aligned to 48 kHz in AudioPlayer.initializeStreaming before any bytes flow, eliminating the mid-decode context teardown that previously OOM'd the tab under software rendering.

Visualizer / decode contention (decodePressure + hwAccel):

  • DeepDrftPublic/Interop/audio/decodePressure.ts exports a shared DecodePressureSignal singleton. The audio pipeline (OpusStreamDecoder yield-cap events, PlaybackScheduler underrun-parking events) calls report() on sustained lag; the visualizer calls isUnderPressure() each rAF frame. The signal engages only after ≥ 5 reports within 2500 ms with a 1s minimum hold (hysteresis); a lone startup-ramp blip never engages.
  • Under pressure, WaveformVisualizer.ts throttles its rAF loop to ~15 fps (PRESSURE_THROTTLE_FRAME_MS = 1000/15), cutting main-thread WebGL software-render + physics cost so WebCodecs decode recovers. A no-op under HW accel.
  • DeepDrftPublic/Interop/visualizer/hwAccel.ts probes WEBGL_debug_renderer_info.UNMASKED_RENDERER_WEBGL for software-renderer signatures (SwiftShader, llvmpipe, softpipe, Microsoft Basic Render, etc.). Absence of the debug extension is treated as accelerated — lava is disabled only on positive evidence. On first interactive render, WaveformVisualizerControlState.ApplyCapabilityDefault(bool) applies a one-time scoped default: LavaEnabled = false (the expensive subsystem) when no HW accel is detected, WaveformEnabled stays on. Guarded by _capabilityDefaultApplied; never overrides an explicit user toggle.
  • Known limitation / deferred escalation: HW-accel-off Opus playback is made usable by defaulting lava off and throttling under pressure. If starvation recurs (e.g. waveform-only path also proves too costly, or a software renderer slips the probe), the documented next step is moving Opus WebCodecs decode off the main thread (Web Worker / AudioWorklet) so it stops competing with main-thread rendering. Not a current implementation — a recorded fallback.

Keep this seam clean — it is the most architecturally load-bearing part of the playback path.

Theming and dark mode

  • MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in DeepDrftShared.Client/Common/DeepDrftPalettes.cs. MainLayout.razor mounts <MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" /> — the palettes are not inline in the layout.
  • Dark mode toggles via cookie (darkMode, 365 days). Client-side via JS interop.
  • During server prerender, DarkModeService (in DeepDrftPublic) reads the cookie and seeds DarkModeSettings.IsDarkMode, which carries into WASM render via PersistentComponentState. Avoids "wrong theme flash" on initial paint.
  • DarkModeSettings lives in DeepDrftPublic.Client.Common (consumed by both server prerender and client components).
  • Theme-aware token layer: DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css defines two kinds of CSS custom properties. Source tokens (--deepdrft-navy, --deepdrft-white, --deepdrft-green-accent, etc.) are brand constants — identical in :root and .deepdrft-theme-dark. Theme-aware aliases are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the alias, not the source token, so neutral surfaces invert for free. Current alias families: --deepdrft-page-surface/-text/-text-muted (neutral page backgrounds and text), --deepdrft-play-chip/-glyph/-chip-soft (play-state icon chip and glyph), --deepdrft-popover-surface (default MudBlazor popover background — light: color-mix(navy 4%, white), a near-page-background surface; dark: references source token --deepdrft-popover-surface-dark, a color-mix(navy-mid 80%, green-accent 20%) bluer navy defined once in :root and referenced by both the .deepdrft-theme-dark wrapper block and body.deepdrft-theme-dark so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware --deepdrft-panel-surface/-text/-text-muted/-border/-row-hover family: dark-glass charcoal (sourced from the --deepdrft-panel-ground constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in body.deepdrft-theme-dark because the panels are MudOverlay panels that portal to <body> (same portal scope as popovers); the --deepdrft-panel-ground source token is now consumed only via the dark --deepdrft-panel-surface value.
  • Portaled-popover body-class bridge: MudBlazor popovers portal to <body>, outside the .deepdrft-theme-dark wrapper <div>, so the dark popover token never reached them. Fix: MainLayout.razor stamps deepdrft-theme-dark on <body> via the setBodyThemeClass(isDark) helper in DeepDrftShared.Client/Interop/theme/theme.ts (lazy-imported as _content/DeepDrftShared.Client/js/theme/theme.js). The call fires only on first render or when _isDarkMode actually changes (gated by _lastAppliedDarkMode comparison) to avoid redundant JS calls on unrelated re-renders. The body.deepdrft-theme-dark selector in deepdrft-tokens.css resolves --deepdrft-popover-surface from --deepdrft-popover-surface-dark for these portaled elements.
  • Interactive-accent icon treatment (.dd-accent-icon / .dd-accent-fill): one reusable rule in DeepDrftPublic/wwwroot/styles/deepdrft-styles.css for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in .dd-accent-icon to colour its glyphs green-accent in both themes; add .dd-accent-fill when the container also holds a Color.Secondary filled button that must go green-accent in dark. It is a CSS class (not a palette Color) because no MudBlazor Color enum is green in both themes, and it targets .dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important to beat MudBlazor's standalone .mud-secondary-text (0,1,0) !important on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via Color.Secondary, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (GasLampLit) is self-colored in its SVG (fill="#2A5C4F" on the frame) — no dark-only CSS rule is needed; GasLamp (unlit, light mode) continues to use currentColor and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in DeepDrftPublic.Client/CLAUDE.md.)
  • Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in DeepDrftShared.Client/Common/DDIcons.cs.

TypeScript interop, not raw JS

Audio interop authored in TypeScript under DeepDrftPublic/Interop/audio/, compiled to wwwroot/js/audio/ via Microsoft.TypeScript.MSBuild. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus index.ts exposing window.DeepDrftAudio. tsconfig.json is not copied to output. In dev, raw .ts served from /Interop/ for source-map debugging. A second interop module lives at DeepDrftPublic/Interop/about/about-rail.ts (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).

DeepDrftShared.Client also hosts TypeScript interop. Its tsconfig.json maps rootDir: "Interop"outDir: "wwwroot/js", compiled by the same Microsoft.TypeScript.MSBuild package. Current modules: Interop/parallax/parallax.ts (parallax scroll for ParallaxImage), Interop/knob/knob.ts (capturePointer/releasePointer for RadialKnob), and Interop/theme/theme.ts (setBodyThemeClass(isDark) — stamps/removes deepdrft-theme-dark on <body> so portaled MudBlazor elements inherit the dark popover token; consumed by MainLayout.razor). Consumers lazy-import via the static-asset path _content/DeepDrftShared.Client/js/<module>/<file>.js.

Development Commands

Build and Test

# Build entire solution
dotnet build DeepDrftHome.sln

# Run all tests
dotnet test DeepDrftTests/

# Run specific test class
dotnet test DeepDrftTests/ --filter "ClassName=FileDatabaseTests"

Running Applications

# Run main web application
dotnet run --project DeepDrftPublic

# Run API (dual-database authority and AuthBlocks host)
dotnet run --project DeepDrftAPI

Entity Framework (SQL Database)

# Add migration (from solution root)
dotnet ef migrations add MigrationName --project DeepDrftData --startup-project DeepDrftAPI

# Update database
dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI

Key Configuration Files

All projects load secrets via CredentialTools.ResolvePathOrThrow() from gitignored environment/ files:

  • DeepDrftPublic/appsettings.json: Logging and URL config. Secrets loaded from environment/api.json (DeepDrftAPI base URL via Api:ContentApiUrl).
  • DeepDrftManager/appsettings.json: Logging and URL config. Secrets loaded from environment/api.json (DeepDrftAPI base URL via Api:ContentApiUrl and API key via Api:ContentApiKey). Non-secret upload tunables (in appsettings.json itself, not environment/): Upload:IdleTimeoutSeconds (default 90 — aborts a stalled body-streaming phase) and Upload:ResponseTimeoutSeconds (default 1200 — budget for server-side persist after the body is fully sent).
  • DeepDrftAPI/appsettings.json: Logging and hosting config. Non-secret upload tunable: Upload:StagingPath (default empty → a staging subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (/tmp is a small tmpfs on the Linux host); Startup also points the framework's multipart buffer here via ASPNETCORE_TEMP. Secrets loaded from environment/filedatabase.json (FileDatabase vault path), environment/apikey.json (API key), environment/connections.json (SQL and Auth connection strings), environment/authblocks.json (AuthBlocks JWT/email/admin creds).

Folder-Level Guidance

Folder-level CLAUDE.md files provide specifics on structure, patterns, and commands for each project. Start with the project's folder CLAUDE.md when entering that directory. The root CLAUDE.md here sets the architectural context; folder files answer "what's in this folder and how do I work here?"

Important Patterns

Result Pattern (NetBlocks)

Services return Result, ResultContainer<T>, or ApiResult<T> rather than throwing for expected failure modes. Catch at service boundary, surface via result.

Error Swallowing in FileDatabase

Public Load* / Register* operations in FileDatabase swallow exceptions and return null / false. Callers must check return values. This matches the TypeScript original and is load-bearing — do not change without a design pass.

Pagination

Services build PagingParameters<T> with an OrderBy expression. Switch in the service maps a string sort column to the expression. New sort columns extend this switch. Nulls sort to end (padded sentinel strings / DateOnly.MaxValue).

External Dependencies

  • Entity Framework Core 10.0.1 (PostgreSQL / Npgsql)
  • MudBlazor 8.15.0
  • NUnit 4.4.0
  • NetBlocks (Result patterns)
  • Microsoft.TypeScript.MSBuild (TS compilation)

See individual project files for detailed dependency lists and versions.