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

23 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), 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. 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: FileBinaryMediaBinary (+ 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.

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. DeepDrftContent.TrackService.AddTrackFromWavAsync processes WAV, generates entry GUID, stores audio in vault, returns unpersisted TrackEntity.
  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).

Streaming-first audio playback

The player is not fetch-then-play:

  1. Client calls GET api/track/{id} on DeepDrftContent and receives WAV bytes as a stream (HttpCompletionOption.ResponseHeadersRead).
  2. StreamingAudioPlayerService reads in adaptive 1664 KB chunks, pushes each via AudioInteropService.processStreamingChunk.
  3. TypeScript StreamDecoder parses WAV 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, client issues GET api/track/{id} with Range: bytes={byteOffset}-. Server streams raw bytes from that file-absolute offset with a 206 Partial Content response. 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.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.