23 KiB
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.txtandGET /sitemap.xml, environment-gated viaIWebHostEnvironment.IsProduction()directly — server-side only, no PersistentState bridge — served byCrawlDirectiveControllerwith pure builders inSeo/RobotsTxt.csandSeo/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
/abouteditorial 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 liveWaveformVisualizer), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight viaabout-rail.tsIntersectionObserver interop; registered inLayout/Pages.cs). Home hero stat row (NowPlayingStats.razor) is live-data-backed viaIStatsDataService/StatsClient(named"DeepDrft.API"client) with aPersistentComponentStateprerender bridge;RuntimeFormathelper converts mix runtime seconds tohh:mm. SEO component (Controls/SeoHead.razor+Common/SeoModel,SeoJsonLd,SeoOptions,SeoUrls,SeoEnvironment):SeoHeadis a presentational<HeadContent>emitter (one line per page, no fetch);SeoModelnamed factories (ForRelease/ForHome/ForAbout/ForBrowse/ForNotFound) encode the medium→schema.org mapping in one place;SeoJsonLdbuilds typed JSON-LD (MusicGroup / MusicAlbum+LiveAlbum / MusicRecording / CollectionPage) with inline-safe escaping;SeoOptionsholds site-wide config (BaseUrl https://deepdrft.com, title suffix, default OG image seam, IGsameAs) registered via the staticStartupseam;SeoEnvironmentis a scoped[PersistentState]bridge (mirrorsDarkModeSettings) seeded inDeepDrftPublic/Components/App.razorfromIWebHostEnvironment.IsProduction()— robots defaults toindex,followonly in Production,noindex,nofolloweverywhere else (fail-safe is noindex); per-pageSeoModel.Robotsoverrides the default. Tags are present in prerendered HTML (rides the existingPersistentComponentStatebridge; no new fetch). Canonical/OG origins come fromSeoOptions.BaseUrl(config), notwindow.location— nowindowat 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
InteractiveServerrender mode. Always uncrawlable: a staticwwwroot/robots.txt(Disallow: /, no env gate) plus a blanket<meta name="robots" content="noindex,nofollow">inComponents/App.razor— defense in depth so the CMS is never indexed regardless of how it is discovered. Hosts all CMS Razor components and pages underComponents/Pages/Cms/,Components/Pages/Tracks/,Components/Layout/CmsLayout.razor, andComponents/Shared/(all inlined from the formerDeepDrftCmsRCL). Public entry point:Components/Pages/Home.razor(@page "/", no[Authorize], uses leanCmsHomeLayout) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to/catalogueviaRedirectToCatalogue.Routes.razorresolvesDefaultLayoutfrom the cascadedTask<AuthenticationState>: unauthenticated →CmsHomeLayout, authenticated →CmsLayout; this means the AuthBlocksLogin/Registerpages (which declare no@layout) render in the lean layout for unauthenticated visitors.CmsLayoutcarries a leftMudDrawer(app-bar hamburger toggle) holding the CMS destinations (Catalogue/catalogue, Releases/releases, Upload/tracks/upload), the AuthBlocksUserAdminMenufragment (self-gates toUserAdmin+, links Users/Registrations/Permissions), and a "Provision User" link to/useradmin/users/newwrapped in aHierarchicalRoleAuthorizeView(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 withCmsLayout; its cards are CUTS / SESSIONS / MIXES, each deep-linking to/releases?medium=<medium>with the matching tab pre-selected. The consolidated browse surface isComponents/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 inCmsAlbumBrowser's expanded child-row track table. Old list routes/tracks,/tracks/albums,/tracks/archiveare kept as aliases onReleases.razorso bookmarks don't 404; operational sub-routes (/tracks/upload, edit routes, etc.) remain at/tracks/*. Gated by AuthBlocks login and hierarchicalAdminrole authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies viaICmsTrackService/CmsTrackServiceinjected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance inBatchEdit/BatchTrackList/BatchTrackDetailswaps the vault bytes, regenerates both waveform datums server-side, and re-derivesDurationSecondsfrom 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) andDeepDrft.Content.Cms.Upload(InfiniteTimeSpan, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a singleProgressStreamContentwrapper (Services/ProgressStreamContent.cs);CmsTrackService.UploadTrackAsyncadds 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.razorcallsGET api/track/release/existsas 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 (NameIdentifierclaim) is captured once into_createdByUserIdat 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 runsprerender: 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 asreleaseIdon rows 2..N (the ATTACH path), whileBatchEdit.razoruses the same ATTACH path for its legitimate adds-to-existing-release. - DeepDrftShared.Client: Razor Class Library. Shared Blazor components consumed by both
DeepDrftPublicandDeepDrftManagerfor consistency across public and admin surfaces. - DeepDrftData: Class library. EF Core domain logic:
DeepDrftContext,TrackConfiguration,Migrations,TrackRepository,TrackService,TrackManager. Consumed byDeepDrftAPIand 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-waveformsvault), release-track join operations,POST api/track/duration/backfill(ApiKey-gated one-time backfill ofDurationSecondsfor existing rows from vault audio). Stats endpoints:GET api/stats/home(unauthenticated; returnsHomeStatsDtowith cut track count, per-ReleaseTypecut 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-sideTrackService. 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).
-
SQL Database (PostgreSQL): Metadata and track info via Entity Framework
- Connection string: Read from
environment/connections.jsonviaCredentialTools.ResolvePathOrThrow("connections")with keyConnectionStrings:DefaultConnection. - Entity:
TrackEntitywithId,EntryKey,TrackName,Artist,Album?,Genre?,ReleaseDate?,ImagePath?,DurationSeconds? - Context:
DeepDrftContextinDeepDrftData
- Connection string: Read from
-
FileDatabase: Custom file-based binary storage system
- Location:
../Database/Vaults(configurable viafiledatabase.json) - Root contains typed MediaVaults (Media, Image, Audio)
- Each vault has a JSON
indexfile listing entries + per-entry metadata - Entries are user-supplied strings sanitized to
[a-zA-Z0-9-]+ file extension - Binary hierarchy:
FileBinary→MediaBinary(+ Extension/MIME) →AudioBinary(+ Duration/Bitrate) |ImageBinary(+ AspectRatio) - Error-handling philosophy: public operations swallow exceptions and return
null/false— callers must check return values, not catch.
- Location:
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:
DeepDrftContent.TrackService.AddTrackFromWavAsyncprocesses WAV, generates entry GUID, stores audio in vault, returns unpersistedTrackEntity.DeepDrftAPI.Services.UnifiedTrackService.UploadAsyncpersists the entity to SQL viaDeepDrftData.TrackManagerand returns the persisted entity withId.
If step 1 succeeds and step 2 fails, audio is orphaned in the vault (no rollback today).
Streaming-first audio playback
The player is not fetch-then-play:
- Client calls
GET api/track/{id}on DeepDrftContent and receives WAV bytes as a stream (HttpCompletionOption.ResponseHeadersRead). StreamingAudioPlayerServicereads in adaptive 16–64 KB chunks, pushes each viaAudioInteropService.processStreamingChunk.- TypeScript
StreamDecoderparses WAV header, decodes chunks toAudioBuffers.PlaybackSchedulerschedules them on a Web Audio graph. - Playback starts as soon as a min buffer is queued; UI duration from parsed header (not waiting for full file).
- Seek beyond buffer: if seek target is past what's decoded, client issues
GET api/track/{id}withRange: bytes={byteOffset}-. Server streams raw bytes from that file-absolute offset with a206 Partial Contentresponse. Player retains the parsed WAV header and feeds the raw PCM continuation into the existing decode pipeline.
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.razormounts<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(inDeepDrftPublic) reads the cookie and seedsDarkModeSettings.IsDarkMode, which carries into WASM render viaPersistentComponentState. Avoids "wrong theme flash" on initial paint. DarkModeSettingslives inDeepDrftPublic.Client.Common(consumed by both server prerender and client components).- Theme-aware token layer:
DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.cssdefines two kinds of CSS custom properties. Source tokens (--deepdrft-navy,--deepdrft-white,--deepdrft-green-accent, etc.) are brand constants — identical in:rootand.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, acolor-mix(navy-mid 80%, green-accent 20%)bluer navy defined once in:rootand referenced by both the.deepdrft-theme-darkwrapper block andbody.deepdrft-theme-darkso 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-hoverfamily: dark-glass charcoal (sourced from the--deepdrft-panel-groundconstant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared inbody.deepdrft-theme-darkbecause the panels are MudOverlay panels that portal to<body>(same portal scope as popovers); the--deepdrft-panel-groundsource token is now consumed only via the dark--deepdrft-panel-surfacevalue. - Portaled-popover body-class bridge: MudBlazor popovers portal to
<body>, outside the.deepdrft-theme-darkwrapper<div>, so the dark popover token never reached them. Fix:MainLayout.razorstampsdeepdrft-theme-darkon<body>via thesetBodyThemeClass(isDark)helper inDeepDrftShared.Client/Interop/theme/theme.ts(lazy-imported as_content/DeepDrftShared.Client/js/theme/theme.js). The call fires only on first render or when_isDarkModeactually changes (gated by_lastAppliedDarkModecomparison) to avoid redundant JS calls on unrelated re-renders. Thebody.deepdrft-theme-darkselector indeepdrft-tokens.cssresolves--deepdrft-popover-surfacefrom--deepdrft-popover-surface-darkfor these portaled elements. - Interactive-accent icon treatment (
.dd-accent-icon/.dd-accent-fill): one reusable rule inDeepDrftPublic/wwwroot/styles/deepdrft-styles.cssfor 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-iconto colour its glyphs green-accent in both themes; add.dd-accent-fillwhen the container also holds aColor.Secondaryfilled button that must go green-accent in dark. It is a CSS class (not a paletteColor) because no MudBlazorColorenum is green in both themes, and it targets.dd-accent-icon .mud-icon-button .mud-icon-root(0,3,0)!importantto beat MudBlazor's standalone.mud-secondary-text(0,1,0)!importanton 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 viaColor.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 usecurrentColorand inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail inDeepDrftPublic.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 fromenvironment/api.json(DeepDrftAPI base URL viaApi:ContentApiUrl).DeepDrftManager/appsettings.json: Logging and URL config. Secrets loaded fromenvironment/api.json(DeepDrftAPI base URL viaApi:ContentApiUrland API key viaApi:ContentApiKey). Non-secret upload tunables (inappsettings.jsonitself, notenvironment/):Upload:IdleTimeoutSeconds(default 90 — aborts a stalled body-streaming phase) andUpload: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 → astagingsubdirectory 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 (/tmpis a small tmpfs on the Linux host);Startupalso points the framework's multipart buffer here viaASPNETCORE_TEMP. Secrets loaded fromenvironment/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.